返回 CodeWhale
goal.rs
根目录 / crates / tui / src / commands / groups / project / goal.rs
1 //! `/goal` — codex-style thread goals: set, inspect, pause, resume, and close
2 //! a durable objective. The engine owns the goal: setting or resuming one
3 //! starts work through the runtime's continuation steering, never by echoing
4 //! the objective back as a user message.
5 //!
6 //! FEAT-021 converts this handler to the portable command contract: it
7 //! consumes the typed project goal projection and the presentation facet only.
8 //! `CommandResult` and the emitted `AppAction` variants (`SetGoalStatus`,
9 //! `SetGoalObjective`, `SendMessage`) remain temporary TUI-owned data-only
10 //! references until FEAT-037.
11
12 use codewhale_command_contract::facets::{
13 CommandPresentationContext, ProjectGoalState, ProjectGoalStatus,
14 };
15 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
16 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
17
18 use crate::tui::app::AppAction;
19
20 use crate::commands::CommandResult;
21
22 /// Translate one stable project key through the presentation facet.
23 fn translate(presentation: &mut dyn CommandPresentationContext, key: &str) -> String {
24 presentation.translate(key, &[]).unwrap_or_default()
25 }
26
27 /// Map the portable goal status onto the TUI-owned action payload status.
28 ///
29 /// This is the bounded FEAT-037 temporary action reference: the handler only
30 /// constructs data-only `AppAction` payloads and never touches the goal
31 /// service, config, or session manager.
32 fn to_action_status(status: ProjectGoalStatus) -> crate::tools::goal::GoalStatus {
33 match status {
34 ProjectGoalStatus::Active => crate::tools::goal::GoalStatus::Active,
35 ProjectGoalStatus::Paused => crate::tools::goal::GoalStatus::Paused,
36 ProjectGoalStatus::Complete => crate::tools::goal::GoalStatus::Complete,
37 ProjectGoalStatus::Blocked => crate::tools::goal::GoalStatus::Blocked,
38 }
39 }
40
41 /// Declare, show, pause, resume, or close a goal.
42 fn goal_command(
43 goal: &ProjectGoalState,
44 presentation: &mut dyn CommandPresentationContext,
45 arg: Option<&str>,
46 ) -> CommandResult {
47 match arg {
48 Some("clear") | Some("reset") => CommandResult::action(AppAction::SetGoalStatus {
49 status: crate::tools::goal::GoalStatus::Active,
50 clear: true,
51 }),
52 Some("done") | Some("complete") => {
53 close_goal(goal, presentation, ProjectGoalStatus::Complete)
54 }
55 Some("pause") | Some("paused") => close_goal(goal, presentation, ProjectGoalStatus::Paused),
56 Some("resume") | Some("continue") => resume_goal(goal, presentation),
57 Some("help") | Some("?") | Some("usage") => CommandResult::message(goal_usage()),
58 Some("status") | Some("show") => goal_status(goal, presentation),
59 Some("block") | Some("blocked") => {
60 close_goal(goal, presentation, ProjectGoalStatus::Blocked)
61 }
62 Some(text) if !text.is_empty() => {
63 let (objective, budget) = parse_goal_budget(text);
64 if objective.is_empty() || objective.chars().all(|c| c == '|') {
65 return CommandResult::error(goal_usage());
66 }
67 // The command layer never mutates the visible projection. The UI
68 // first persists and accepts this typed intent; only the engine's
69 // authoritative GoalUpdated event may change what the user sees.
70 CommandResult::action(AppAction::SetGoalObjective {
71 objective,
72 token_budget: budget,
73 })
74 }
75 _ => {
76 if goal.pending_controls {
77 CommandResult::message(translate(presentation, "goal_control_accepted"))
78 } else if goal.objective.is_some() {
79 goal_status(goal, presentation)
80 } else if !goal.conversation_present {
81 // Nothing has happened yet: there is no context to derive an
82 // objective from, so answer with usage instead of spending a
83 // model turn on a question we already know the answer to.
84 CommandResult::message(goal_usage())
85 } else {
86 // Context-dependent bare /goal: with no active goal, the
87 // invocation itself is the ask — derive the objective from
88 // the conversation instead of demanding a restatement
89 // (mirrors bare /workflow). The end-of-turn GoalUpdated
90 // snapshot syncs the created goal into the sidebar.
91 let message = BARE_GOAL_BRIEF.to_string();
92 CommandResult::with_message_and_action(
93 "Declaring a goal from the current context...",
94 AppAction::SendMessage(message),
95 )
96 }
97 }
98 }
99 }
100
101 /// Model-facing brief for a context-dependent bare `/goal`. `create_goal`
102 /// is eager on the default catalog, but a narrowed surface can defer it, so
103 /// the brief carries the activation path (#6747).
104 pub(crate) const BARE_GOAL_BRIEF: &str = "The user invoked /goal with no objective — declare a goal for the \
105 CURRENT work. Synthesize the objective from the conversation context (the \
106 task in flight, recent findings, open items) and set it by calling \
107 `create_goal` with the full objective (and a token_budget only if one was \
108 discussed); if `create_goal` is not in your tool list, load it with `tool_search` \
109 first. Then continue working toward it. Only if the conversation \
110 genuinely contains no work yet, ask the user what the goal should be.";
111
112 /// Plain status line: objective, state, elapsed, budget, continuations, and
113 /// — for an active goal that no turn is driving right now — how to continue.
114 fn goal_status(
115 goal: &ProjectGoalState,
116 presentation: &mut dyn CommandPresentationContext,
117 ) -> CommandResult {
118 let Some(obj) = goal.objective.as_deref() else {
119 return CommandResult::message(goal_usage());
120 };
121 let elapsed = if goal.time_used_seconds > 0 {
122 format_elapsed(goal.time_used_seconds)
123 } else if let Some(secs) = goal.started_at_elapsed_seconds {
124 format_elapsed(secs)
125 } else {
126 "unknown".to_string()
127 };
128 let budget_str = goal
129 .token_budget
130 .map(|b| {
131 let used = if goal.tokens_used > 0 {
132 goal.tokens_used
133 } else {
134 u64::from(goal.session_total_tokens)
135 };
136 let pct = if b > 0 {
137 (used as f64 / f64::from(b) * 100.0).min(100.0)
138 } else {
139 0.0
140 };
141 format!(" · tokens {used}/{b} ({pct:.0}%)")
142 })
143 .unwrap_or_default();
144 let mut state = goal_status_label(goal.status).to_string();
145 if let (ProjectGoalStatus::Paused, Some(reason)) = (goal.status, goal.pause_reason.as_deref()) {
146 state = format!("{state} ({reason})");
147 }
148 let mut line = format!(
149 "Goal {state}: \"{obj}\" · elapsed {elapsed}{budget_str} · continuations {}",
150 goal.continuation_count
151 );
152 if goal.status == ProjectGoalStatus::Active
153 && !goal.is_loading
154 && !goal.goal_continuation_waiting
155 {
156 line.push_str(" · ");
157 line.push_str(&translate(presentation, "goal_status_idle_hint"));
158 }
159 CommandResult::message(line)
160 }
161
162 /// Close out the goal at `status`. Pure control plane: the engine stops (or
163 /// re-arms) the continuation loop from the `SetGoalStatus` op; no model turn
164 /// is dispatched.
165 fn close_goal(
166 goal: &ProjectGoalState,
167 presentation: &mut dyn CommandPresentationContext,
168 status: ProjectGoalStatus,
169 ) -> CommandResult {
170 if effective_goal_objective(goal).is_none_or(str::is_empty) {
171 return CommandResult::error("No goal set. Use /goal <objective> [budget: N] first.");
172 }
173 if effective_goal_status(goal) == status {
174 if goal.pending_controls {
175 return CommandResult::message(translate(presentation, "goal_control_accepted"));
176 }
177 return goal_status(goal, presentation);
178 }
179
180 CommandResult::action(AppAction::SetGoalStatus {
181 status: to_action_status(status),
182 clear: false,
183 })
184 }
185
186 /// Resume a paused goal. The engine restarts the continuation loop itself
187 /// (`SetGoalStatus` → schedule kickoff); the objective is never re-sent as a
188 /// user message.
189 fn resume_goal(
190 goal: &ProjectGoalState,
191 presentation: &mut dyn CommandPresentationContext,
192 ) -> CommandResult {
193 if effective_goal_objective(goal)
194 .map(str::trim)
195 .is_none_or(str::is_empty)
196 {
197 return CommandResult::error("No paused goal set. Use /goal <objective> first.");
198 }
199
200 // Resuming an already-active goal is a no-op: the continuation loop is
201 // already running, and re-asserting Active could stack a second
202 // autonomous turn. Report progress instead.
203 if effective_goal_status(goal) == ProjectGoalStatus::Active {
204 if goal.pending_controls {
205 return CommandResult::message(translate(presentation, "goal_control_accepted"));
206 }
207 return goal_status(goal, presentation);
208 }
209
210 CommandResult::action(AppAction::SetGoalStatus {
211 status: crate::tools::goal::GoalStatus::Active,
212 clear: false,
213 })
214 }
215
216 fn effective_goal_objective(goal: &ProjectGoalState) -> Option<&str> {
217 if goal.pending_controls {
218 goal.last_known_objective.as_deref()
219 } else {
220 goal.objective.as_deref()
221 }
222 }
223
224 fn effective_goal_status(goal: &ProjectGoalState) -> ProjectGoalStatus {
225 if goal.pending_controls {
226 goal.last_known_status.unwrap_or(ProjectGoalStatus::Active)
227 } else {
228 goal.status
229 }
230 }
231
232 fn goal_usage() -> &'static str {
233 "No goal set. /goal <objective> [budget: N] starts one; the agent works toward it \
234 across turns until it is verified complete, blocked, or you stop it.\n\
235 /goal — progress of the current goal\n\
236 /goal pause — pause without continuing\n\
237 /goal resume — resume and continue\n\
238 /goal done — mark complete (skips the model's verification)\n\
239 /goal blocked — mark blocked\n\
240 /goal clear — remove the current goal."
241 }
242
243 fn goal_status_label(status: ProjectGoalStatus) -> &'static str {
244 match status {
245 ProjectGoalStatus::Active => "active",
246 ProjectGoalStatus::Complete => "complete",
247 ProjectGoalStatus::Paused => "paused",
248 ProjectGoalStatus::Blocked => "blocked",
249 }
250 }
251
252 /// Format a whole-seconds duration (portable replica of the TUI leaf helper,
253 /// byte-identical output; the contract never ships preformatted strings).
254 fn format_elapsed(secs: u64) -> String {
255 if secs < 60 {
256 format!("{secs}s")
257 } else {
258 format!("{}m {:02}s", secs / 60, secs % 60)
259 }
260 }
261
262 /// Parse text like "Implement login | budget: 50000" into (objective, budget).
263 fn parse_goal_budget(text: &str) -> (String, Option<u32>) {
264 // Only an explicit, well-formed budget suffix splits the objective.
265 // `budget:` followed by something that is not a number is prose that
266 // belongs to the objective — truncating it would silently rewrite what
267 // the user asked for.
268 for separator in [" | budget:", " budget:", "budget:"] {
269 if let Some((objective, rest)) = text.split_once(separator) {
270 let budget = rest
271 .split_whitespace()
272 .next()
273 .and_then(|value| value.parse::<u32>().ok());
274 if let Some(budget) = budget {
275 return (objective.trim().to_string(), Some(budget));
276 }
277 }
278 }
279 (text.trim().to_string(), None)
280 }
281
282 pub(in crate::commands) const GOAL_INFO: CommandInfo = CommandInfo {
283 name: "goal",
284 aliases: &[],
285 usage: "/goal [objective|status|pause|resume|done|blocked|clear] [budget: N]",
286 description_key: "cmd_goal_description",
287 };
288
289 pub(in crate::commands) struct GoalCmd;
290
291 impl RegisterCommand<CommandResult> for GoalCmd {
292 fn info() -> &'static CommandInfo {
293 &GOAL_INFO
294 }
295
296 fn handler() -> CommandHandler<CommandResult> {
297 CommandHandler::Contextual {
298 capabilities: codewhale_command_contract::handler::CommandCapabilities::PROJECT
299 .union(codewhale_command_contract::handler::CommandCapabilities::PRESENTATION),
300 handler: goal_contextual,
301 }
302 }
303 }
304
305 /// Contextual `/goal` dispatch (FEAT-021 Phase 4).
306 ///
307 /// Destructures the declared `PROJECT | PRESENTATION` facets with safe
308 /// missing-facet errors; the portable handler never panics on absent
309 /// capabilities and never emits a partial action.
310 fn goal_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
311 let mut parts = contexts.into_parts();
312 let Some(project) = parts.project.as_deref_mut() else {
313 return CommandResult::error("Command capability unavailable: project");
314 };
315 let Some(presentation) = parts.presentation.as_deref_mut() else {
316 return CommandResult::error("Command capability unavailable: presentation");
317 };
318 let goal = project.goal_state();
319 goal_command(&goal, presentation, arg)
320 }
321
322 #[cfg(test)]
323 mod tests {
324 /// #6747: the bare /goal brief names only callable tools and teaches
325 /// the `tool_search` activation path.
326 #[test]
327 fn bare_goal_brief_names_only_callable_tools() {
328 crate::tools::canonical_action::tests::assert_text_names_only_callable_tools(
329 "bare /goal brief",
330 BARE_GOAL_BRIEF,
331 );
332 assert!(BARE_GOAL_BRIEF.contains("`tool_search`"));
333 }
334
335 use super::*;
336 use codewhale_command_contract::facets::CommandProjectContext;
337
338 /// Deterministic fake project facet over portable values only.
339 struct FakeProject;
340
341 impl FakeProject {
342 fn new() -> Self {
343 Self
344 }
345 }
346
347 impl CommandProjectContext for FakeProject {
348 fn lsp_enabled(&self) -> bool {
349 false
350 }
351
352 fn lsp_set(&mut self, _enabled: bool) -> Result<(), String> {
353 Ok(())
354 }
355
356 fn goal_state(&self) -> ProjectGoalState {
357 goal_state()
358 }
359 }
360
361 /// Deterministic fake presentation facet over portable values only.
362 struct FakePresentation;
363
364 impl CommandPresentationContext for FakePresentation {
365 fn translate(&self, key: &str, _replacements: &[(&str, &str)]) -> Result<String, String> {
366 match key {
367 "goal_control_accepted" => {
368 Ok("Goal control saved; applying at the next safe boundary.".to_string())
369 }
370 "goal_status_idle_hint" => {
371 Ok("not running now — send a message or /goal resume to continue".to_string())
372 }
373 other => Err(format!("unknown key {other}")),
374 }
375 }
376 }
377
378 fn goal_state() -> ProjectGoalState {
379 ProjectGoalState {
380 objective: None,
381 status: ProjectGoalStatus::Active,
382 pause_reason: None,
383 started_at_elapsed_seconds: None,
384 time_used_seconds: 0,
385 token_budget: None,
386 tokens_used: 0,
387 session_total_tokens: 0,
388 continuation_count: 0,
389 pending_controls: false,
390 last_known_objective: None,
391 last_known_status: None,
392 conversation_present: false,
393 is_loading: false,
394 goal_continuation_waiting: false,
395 }
396 }
397
398 fn run(goal: &ProjectGoalState, arg: Option<&str>) -> CommandResult {
399 let mut presentation = FakePresentation;
400 goal_command(goal, &mut presentation, arg)
401 }
402
403 #[test]
404 fn test_set_goal_dispatches_control_plane_not_user_echo() {
405 let goal = goal_state();
406 let result = run(&goal, Some("Fix the login bug"));
407 assert!(result.message.is_none());
408 // The engine owns the kickoff: the objective must reach it as a
409 // SetGoalObjective control op, never as a SendMessage user echo.
410 assert!(matches!(
411 result.action,
412 Some(AppAction::SetGoalObjective { ref objective, token_budget: None })
413 if objective == "Fix the login bug"
414 ));
415 }
416
417 #[test]
418 fn test_goal_budget_parsing_reaches_the_op() {
419 let goal = goal_state();
420 let result = run(&goal, Some("Ship 0.9.10 | budget: 5000"));
421 assert!(matches!(
422 result.action,
423 Some(AppAction::SetGoalObjective { ref objective, token_budget: Some(5000) })
424 if objective == "Ship 0.9.10"
425 ));
426 }
427
428 #[test]
429 fn pause_and_clear_are_control_ops_without_optimistic_state() {
430 let mut goal = goal_state();
431 goal.objective = Some("Keep the build green".to_string());
432 goal.status = ProjectGoalStatus::Active;
433 let paused = run(&goal, Some("pause"));
434 assert!(paused.message.is_none());
435 assert!(matches!(
436 paused.action,
437 Some(AppAction::SetGoalStatus {
438 status: crate::tools::goal::GoalStatus::Paused,
439 clear: false
440 })
441 ));
442
443 let cleared = run(&goal, Some("clear"));
444 assert!(cleared.message.is_none());
445 assert!(matches!(
446 cleared.action,
447 Some(AppAction::SetGoalStatus {
448 status: crate::tools::goal::GoalStatus::Active,
449 clear: true
450 })
451 ));
452 }
453
454 #[test]
455 fn test_goal_without_argument_synthesizes_goal_from_context() {
456 // Bare /goal with no active goal is context-dependent: the model
457 // derives the objective from the conversation and sets it via
458 // create_goal — it must not error with a usage demand.
459 let mut goal = goal_state();
460 goal.conversation_present = true;
461 let result = run(&goal, None);
462 assert!(!result.is_error);
463 let Some(AppAction::SendMessage(message)) = result.action else {
464 panic!("expected SendMessage action");
465 };
466 assert!(message.contains("Synthesize the objective from the conversation"));
467 assert!(message.contains("`create_goal`"));
468 }
469
470 #[test]
471 fn bare_goal_on_an_empty_session_prints_usage_without_a_model_turn() {
472 // No conversation yet: there is nothing to derive an objective from,
473 // so the answer is usage — free, and not a question to the model.
474 let goal = goal_state();
475 let result = run(&goal, None);
476 assert!(!result.is_error);
477 assert!(result.action.is_none());
478 assert!(result.message.unwrap().contains("/goal <objective>"));
479 }
480
481 #[test]
482 fn goal_status_reports_objective_and_state() {
483 let mut goal = goal_state();
484 goal.objective = Some("Make the suite green".to_string());
485 goal.token_budget = Some(100);
486 goal.status = ProjectGoalStatus::Active;
487 let result = run(&goal, Some("status"));
488 let line = result.message.unwrap();
489 assert!(line.contains("Make the suite green"));
490 assert!(line.contains("active"));
491 assert!(line.contains("100"));
492 }
493
494 #[test]
495 fn goal_status_includes_elapsed_and_continuations() {
496 let mut goal = goal_state();
497 goal.objective = Some("Ship it".to_string());
498 goal.status = ProjectGoalStatus::Active;
499 goal.time_used_seconds = 125;
500 goal.continuation_count = 3;
501 let result = run(&goal, Some("status"));
502 let line = result.message.unwrap();
503 assert!(line.contains("elapsed 2m 05s"), "line: {line}");
504 assert!(line.contains("continuations 3"), "line: {line}");
505 }
506
507 #[test]
508 fn paused_goal_status_shows_reason() {
509 let mut goal = goal_state();
510 goal.objective = Some("Ship it".to_string());
511 goal.status = ProjectGoalStatus::Paused;
512 goal.pause_reason = Some("usage limit".to_string());
513 let result = run(&goal, Some("status"));
514 assert!(result.message.unwrap().contains("paused (usage limit)"));
515 }
516
517 #[test]
518 fn resume_on_an_active_goal_is_a_no_op_report() {
519 // Re-asserting Active while the loop is already running must not
520 // schedule a second autonomous turn.
521 let mut goal = goal_state();
522 goal.objective = Some("Keep the build green".to_string());
523 goal.status = ProjectGoalStatus::Active;
524 let resumed = run(&goal, Some("resume"));
525 assert!(!resumed.is_error);
526 assert!(
527 resumed.action.is_none(),
528 "no control op on already-active goal"
529 );
530 assert!(resumed.message.unwrap().contains("Keep the build green"));
531 }
532
533 #[test]
534 fn invalid_budget_suffix_stays_part_of_the_objective() {
535 let goal = goal_state();
536 let result = run(&goal, Some("Fix budget: handling in settings"));
537 assert!(matches!(
538 result.action,
539 Some(AppAction::SetGoalObjective { ref objective, token_budget: None })
540 if objective == "Fix budget: handling in settings"
541 ));
542 }
543
544 #[test]
545 fn completing_a_goal_without_one_is_an_error() {
546 let goal = goal_state();
547 let result = run(&goal, Some("done"));
548 assert!(result.is_error);
549 }
550
551 #[test]
552 fn goal_help_route_returns_usage() {
553 let goal = goal_state();
554 for arg in ["help", "?", "usage"] {
555 let result = run(&goal, Some(arg));
556 assert!(!result.is_error);
557 assert!(result.message.unwrap().contains("/goal <objective>"));
558 }
559 }
560
561 #[test]
562 fn missing_project_facet_fails_safely() {
563 let result = goal_contextual(CommandContexts::empty(), Some("status"));
564 assert!(result.is_error);
565 assert!(
566 result
567 .message
568 .unwrap()
569 .contains("Command capability unavailable: project")
570 );
571 }
572
573 #[test]
574 fn missing_presentation_facet_fails_safely() {
575 // PROJECT present but PRESENTATION absent: safe error, no partial action.
576 let mut project = FakeProject::new();
577 let contexts = CommandContexts::empty().with_project(&mut project);
578 let result = goal_contextual(contexts, Some("status"));
579 assert!(result.is_error);
580 assert!(
581 result.action.is_none(),
582 "no partial action on missing facet"
583 );
584 assert!(
585 result
586 .message
587 .unwrap()
588 .contains("Command capability unavailable: presentation")
589 );
590 }
591
592 #[test]
593 fn format_elapsed_matches_tui_leaf_helper() {
594 // The portable replica must stay byte-identical to the TUI elapsed
595 // helper (Phase 4 review recommendation).
596 for secs in [0, 1, 59, 60, 61, 125, 3599, 3600, 3601] {
597 assert_eq!(
598 format_elapsed(secs),
599 crate::elapsed::format_elapsed_secs(secs),
600 "format_elapsed({secs}) must equal the TUI helper"
601 );
602 }
603 }
604
605 #[test]
606 fn goal_control_accepted_translates_through_fake_presentation() {
607 // Pending controls route to the translated message, not the raw key.
608 let mut goal = goal_state();
609 goal.objective = Some("Keep the build green".to_string());
610 goal.status = ProjectGoalStatus::Active;
611 goal.pending_controls = true;
612 goal.last_known_objective = Some("Keep the build green".to_string());
613 goal.last_known_status = Some(ProjectGoalStatus::Paused);
614 let result = run(&goal, Some("pause"));
615 assert!(!result.is_error);
616 let msg = result.message.expect("pending-control message");
617 assert!(msg.contains("Goal control saved"));
618 assert!(
619 !msg.contains("goal_control_accepted"),
620 "raw key must not leak"
621 );
622 }
623
624 #[test]
625 fn idle_hint_translates_through_fake_presentation() {
626 // Active goal not being driven: the idle hint is the translated text.
627 let mut goal = goal_state();
628 goal.objective = Some("Ship it".to_string());
629 goal.status = ProjectGoalStatus::Active;
630 goal.is_loading = false;
631 goal.goal_continuation_waiting = false;
632 let result = run(&goal, Some("status"));
633 let line = result.message.unwrap();
634 assert!(
635 line.contains("not running now"),
636 "idle hint must be translated: {line}"
637 );
638 assert!(
639 !line.contains("goal_status_idle_hint"),
640 "raw key must not leak"
641 );
642 }
643
644 #[test]
645 fn goal_token_fallback_uses_session_total() {
646 // tokens_used == 0 falls back to the session conversation-token total.
647 let mut goal = goal_state();
648 goal.objective = Some("Ship it".to_string());
649 goal.token_budget = Some(100);
650 goal.tokens_used = 0;
651 goal.session_total_tokens = 42;
652 let result = run(&goal, Some("status"));
653 let line = result.message.unwrap();
654 assert!(line.contains("tokens 42/100 (42%)"), "line: {line}");
655 }
656
657 #[test]
658 fn goal_token_uses_engine_count_when_nonzero() {
659 let mut goal = goal_state();
660 goal.objective = Some("Ship it".to_string());
661 goal.token_budget = Some(100);
662 goal.tokens_used = 10;
663 goal.session_total_tokens = 42;
664 let result = run(&goal, Some("status"));
665 let line = result.message.unwrap();
666 assert!(line.contains("tokens 10/100 (10%)"), "line: {line}");
667 }
668 }
669
669 lines RUST