返回 CodeWhale
goal.rs
根目录 / crates / tui / src / commands / groups / project / goal.rs
1 //! /goal command, with /hunt kept as a compatibility alias (#2092).
2
3 use std::io::Write;
4
5 use crate::commands::traits::{CommandInfo, RegisterCommand};
6 use crate::localization::MessageId;
7 use crate::tools::goal::GoalStatus;
8 use crate::tui::app::{App, AppAction, HuntVerdict};
9 use serde_json::{Value, json};
10
11 use crate::commands::CommandResult;
12
13 /// Declare, show, pause, resume, or close a goal.
14 fn hunt(app: &mut App, arg: Option<&str>) -> CommandResult {
15 match arg {
16 Some("clear") | Some("reset") => {
17 app.hunt.quarry = None;
18 app.hunt.token_budget = None;
19 app.hunt.tokens_used = 0;
20 app.hunt.time_used_seconds = 0;
21 app.hunt.continuation_count = 0;
22 app.hunt.started_at = None;
23 app.hunt.finished_at = None;
24 app.hunt.verdict = HuntVerdict::default();
25 CommandResult::with_message_and_action(
26 "Goal cleared.",
27 AppAction::SetGoalStatus {
28 status: GoalStatus::Active,
29 clear: true,
30 },
31 )
32 }
33 Some("declare-hunted")
34 | Some("declare_hunted")
35 | Some("force-complete")
36 | Some("force_complete") => declare_hunted(app),
37 Some("done") | Some("complete") | Some("hunted") => {
38 close_hunt(app, HuntVerdict::Hunted, GoalStatus::Complete)
39 }
40 Some("pause") | Some("paused") | Some("wound") | Some("wounded") => {
41 close_hunt(app, HuntVerdict::Wounded, GoalStatus::Paused)
42 }
43 Some("resume") | Some("continue") => resume_hunt(app),
44 Some("block") | Some("blocked") | Some("escape") | Some("escaped") => {
45 close_hunt(app, HuntVerdict::Escaped, GoalStatus::Blocked)
46 }
47 Some(text) if !text.is_empty() => {
48 let (objective, budget) = parse_hunt_budget(text);
49 if objective.is_empty() || objective.chars().all(|c| c == '|') {
50 return CommandResult::error(goal_usage());
51 }
52 app.hunt.quarry = Some(objective.clone());
53 app.hunt.token_budget = budget;
54 app.hunt.tokens_used = 0;
55 app.hunt.time_used_seconds = 0;
56 app.hunt.continuation_count = 0;
57 app.hunt.started_at = Some(std::time::Instant::now());
58 app.hunt.finished_at = None;
59 app.hunt.verdict = HuntVerdict::Hunting;
60 let budget_str = budget
61 .map(|b| format!(" (budget: {b} tokens)"))
62 .unwrap_or_default();
63 CommandResult::with_message_and_action(
64 format!("Goal set: \"{objective}\"{budget_str} - tracking progress."),
65 AppAction::SendMessage(objective),
66 )
67 }
68 _ => {
69 if let Some(ref obj) = app.hunt.quarry {
70 let elapsed = app
71 .hunt
72 .time_used_seconds
73 .gt(&0)
74 .then(|| crate::elapsed::format_elapsed_secs(app.hunt.time_used_seconds))
75 .or_else(|| {
76 app.hunt
77 .started_at
78 .map(|t| crate::elapsed::format_elapsed_secs(t.elapsed().as_secs()))
79 })
80 .unwrap_or_else(|| "unknown".to_string());
81 let budget_str = app
82 .hunt
83 .token_budget
84 .map(|b| {
85 let used = if app.hunt.tokens_used > 0 {
86 app.hunt.tokens_used
87 } else {
88 u64::from(app.session.total_conversation_tokens)
89 };
90 let pct = if b > 0 {
91 (used as f64 / f64::from(b) * 100.0).min(100.0)
92 } else {
93 0.0
94 };
95 format!(" | tokens: {used}/{b} ({pct:.0}%)")
96 })
97 .unwrap_or_default();
98 let verdict_label = hunt_verdict_label(app.hunt.verdict);
99 CommandResult::message(format!(
100 "Goal {verdict_label}: \"{obj}\" - elapsed: {elapsed}{budget_str} | continuations: {}",
101 app.hunt.continuation_count
102 ))
103 } else {
104 // Context-dependent bare /goal: with no active goal, the
105 // invocation itself is the ask — derive the objective from
106 // the conversation instead of demanding a restatement
107 // (mirrors bare /workflow). The end-of-turn GoalUpdated
108 // snapshot syncs the created goal into the sidebar.
109 let message = "The user invoked /goal with no objective — declare a goal for the \
110 CURRENT work. Synthesize the objective from the conversation context (the \
111 task in flight, recent findings, open items) and set it by calling \
112 `create_goal` with the full objective (and a token_budget only if one was \
113 discussed). Then continue working toward it. Only if the conversation \
114 genuinely contains no work yet, ask the user what the goal should be."
115 .to_string();
116 CommandResult::with_message_and_action(
117 "Declaring a goal from the current context...",
118 AppAction::SendMessage(message),
119 )
120 }
121 }
122 }
123 }
124
125 fn declare_hunted(app: &mut App) -> CommandResult {
126 let previous = app.hunt.verdict;
127 let result = close_hunt(app, HuntVerdict::Hunted, GoalStatus::Complete);
128 if !result.is_error {
129 crate::audit::log_sensitive_event(
130 "goal.declare_hunted",
131 declare_hunted_audit_details(previous, app),
132 );
133 }
134 result
135 }
136
137 fn declare_hunted_audit_details(previous: HuntVerdict, app: &App) -> Value {
138 json!({
139 "previous_verdict": hunt_verdict_name(previous),
140 "current_verdict": hunt_verdict_name(app.hunt.verdict),
141 "has_quarry": app.hunt.quarry.as_deref().is_some_and(|quarry| !quarry.is_empty()),
142 })
143 }
144
145 fn close_hunt(app: &mut App, verdict: HuntVerdict, status: GoalStatus) -> CommandResult {
146 if app.hunt.quarry.as_deref().is_none_or(str::is_empty) {
147 return CommandResult::error("No goal set. Use /goal <objective> [budget: N] first.");
148 }
149
150 let prev = app.hunt.verdict;
151 let should_write_trophy = matches!(verdict, HuntVerdict::Hunted) && prev != verdict;
152 if should_write_trophy && let Err(err) = write_trophy_card(app, verdict) {
153 return CommandResult::error(err);
154 }
155 app.hunt.verdict = verdict;
156 // Freeze the sidebar timer at the moment of close-out so it stops ticking
157 // for hunted/escaped goals. Wounded (paused) goals are not terminal — the
158 // timer re-arms on resume — but we still record the pause instant so a
159 // paused goal doesn't read as still-running in the sidebar.
160 if app.hunt.finished_at.is_none() {
161 app.hunt.finished_at = Some(std::time::Instant::now());
162 }
163
164 // Push the new status to the engine's SharedGoalState so the cross-turn
165 // continuation loop respects it: pause/blocked stops the loop, complete
166 // ends it, resume restarts it.
167 let action = AppAction::SetGoalStatus {
168 status,
169 clear: false,
170 };
171
172 match verdict {
173 HuntVerdict::Hunted => {
174 let elapsed = goal_elapsed_at_close(&app.hunt);
175 CommandResult::with_message_and_action(
176 format!("Goal hunted. Elapsed: {elapsed}"),
177 action,
178 )
179 }
180 HuntVerdict::Wounded => CommandResult::with_message_and_action(
181 "Goal wounded. Progress is saved; use /goal resume to continue.",
182 action,
183 ),
184 HuntVerdict::Escaped => CommandResult::with_message_and_action("Goal escaped.", action),
185 HuntVerdict::Hunting => CommandResult::with_message_and_action("Goal hunting.", action),
186 }
187 }
188
189 fn resume_hunt(app: &mut App) -> CommandResult {
190 let Some(objective) = app
191 .hunt
192 .quarry
193 .as_deref()
194 .map(str::trim)
195 .filter(|objective| !objective.is_empty())
196 .map(str::to_string)
197 else {
198 return CommandResult::error("No paused goal set. Use /goal <objective> first.");
199 };
200
201 app.hunt.verdict = HuntVerdict::Hunting;
202 if app.hunt.started_at.is_none() {
203 app.hunt.started_at = Some(std::time::Instant::now());
204 }
205 // Re-arm the elapsed timer: a resumed goal should keep ticking from where
206 // it left off (started_at is preserved), not stay frozen at the pause.
207 app.hunt.finished_at = None;
208 CommandResult::with_message_and_action("Goal resumed.", AppAction::SendMessage(objective))
209 }
210
211 fn goal_usage() -> &'static str {
212 "No goal set. Use /goal <objective> [budget: N] to set one.\n\
213 /goal declare-hunted - override verification and mark hunted\n\
214 /goal wounded - pause without continuing\n\
215 /goal resume - resume and continue\n\
216 /goal escaped - mark escaped\n\
217 /goal clear - remove the current goal."
218 }
219
220 fn hunt_verdict_label(verdict: HuntVerdict) -> &'static str {
221 match verdict {
222 HuntVerdict::Hunting => "[HUNTING]",
223 HuntVerdict::Hunted => "[HUNTED]",
224 HuntVerdict::Wounded => "[WOUNDED]",
225 HuntVerdict::Escaped => "[ESCAPED]",
226 }
227 }
228
229 /// Humanized elapsed time for a closed goal, frozen at the finish instant so
230 /// the close-out message doesn't drift further each time it's read.
231 fn goal_elapsed_at_close(hunt: &crate::tui::app::HuntState) -> String {
232 match (hunt.started_at, hunt.finished_at) {
233 (Some(started), Some(finished)) => crate::elapsed::format_elapsed_secs(
234 finished.saturating_duration_since(started).as_secs(),
235 ),
236 (Some(started), None) => crate::elapsed::format_elapsed_secs(started.elapsed().as_secs()),
237 (None, _) => "unknown".to_string(),
238 }
239 }
240
241 fn hunt_verdict_name(verdict: HuntVerdict) -> &'static str {
242 match verdict {
243 HuntVerdict::Hunting => "hunting",
244 HuntVerdict::Hunted => "hunted",
245 HuntVerdict::Wounded => "wounded",
246 HuntVerdict::Escaped => "escaped",
247 }
248 }
249
250 /// Parse text like "Implement login | budget: 50000" into (objective, budget).
251 fn parse_hunt_budget(text: &str) -> (String, Option<u32>) {
252 if let Some((obj, rest)) = text.split_once(" | budget:") {
253 let budget = rest
254 .split_whitespace()
255 .next()
256 .and_then(|s| s.parse::<u32>().ok());
257 (obj.trim().to_string(), budget)
258 } else if let Some((obj, rest)) = text.split_once("budget:") {
259 let budget = rest
260 .split_whitespace()
261 .next()
262 .and_then(|s| s.parse::<u32>().ok());
263 (obj.trim().to_string(), budget)
264 } else {
265 (text.trim().to_string(), None)
266 }
267 }
268
269 /// Write a legacy trophy card to `~/.codewhale/trophies/<date>-<time>-<slug>.md`
270 /// for the current goal result (#2092).
271 fn write_trophy_card(app: &App, verdict: HuntVerdict) -> Result<std::path::PathBuf, String> {
272 let quarry = app
273 .hunt
274 .quarry
275 .as_deref()
276 .ok_or_else(|| "No goal set. Use /goal <objective> [budget: N] first.".to_string())?;
277 // Collapse consecutive non-alphanumeric chars into a single '-'
278 let mut slug = String::new();
279 let mut last_dash = false;
280 for c in quarry.chars() {
281 if c.is_alphanumeric() {
282 slug.push(c.to_ascii_lowercase());
283 last_dash = false;
284 } else if !last_dash {
285 slug.push('-');
286 last_dash = true;
287 }
288 }
289 let slug = slug.trim_matches('-');
290 if slug.is_empty() {
291 return Err(
292 "Cannot write trophy card: goal objective has no filename-safe characters.".into(),
293 );
294 }
295 let now = chrono::Local::now();
296 let time = now.format("%H%M%S");
297 let date = now.format("%Y-%m-%d");
298 let date_str = date.to_string();
299 let now_str = now.to_string();
300 let dir = codewhale_config::ensure_state_dir("trophies")
301 .map_err(|err| format!("Could not resolve trophy directory: {err}"))?;
302 // Include time in filename to avoid collisions on same-date hunts.
303 let filename = format!("{date}-{time}-{slug}.md");
304 let path = dir.join(&filename);
305
306 let elapsed = app
307 .hunt
308 .started_at
309 .as_ref()
310 .map(|t| crate::elapsed::format_elapsed_secs(t.elapsed().as_secs()))
311 .unwrap_or_else(|| "unknown".to_string());
312 let verdict_str = hunt_verdict_name(verdict);
313 let tokens = if app.hunt.tokens_used > 0 {
314 u32::try_from(app.hunt.tokens_used).unwrap_or(u32::MAX)
315 } else {
316 app.session.total_conversation_tokens
317 };
318 let budget_str = app
319 .hunt
320 .token_budget
321 .map(|b| format!("{b}"))
322 .unwrap_or_else(|| "—".to_string());
323
324 let mut f = std::fs::File::create(&path)
325 .map_err(|err| format!("Could not create trophy card {}: {err}", path.display()))?;
326 write_trophy_card_contents(
327 &mut f,
328 TrophyCard {
329 quarry,
330 verdict: verdict_str,
331 date: &date_str,
332 elapsed: &elapsed,
333 tokens,
334 budget: &budget_str,
335 now: &now_str,
336 },
337 )
338 .map_err(|err| format!("Could not write trophy card {}: {err}", path.display()))?;
339
340 Ok(path)
341 }
342
343 struct TrophyCard<'a> {
344 quarry: &'a str,
345 verdict: &'a str,
346 date: &'a str,
347 elapsed: &'a str,
348 tokens: u32,
349 budget: &'a str,
350 now: &'a str,
351 }
352
353 fn write_trophy_card_contents(mut f: impl Write, card: TrophyCard<'_>) -> std::io::Result<()> {
354 writeln!(f, "# Goal result: {}", card.quarry)?;
355 writeln!(f)?;
356 writeln!(f, "- **Verdict**: {}", card.verdict)?;
357 writeln!(f, "- **Date**: {}", card.date)?;
358 writeln!(f, "- **Elapsed**: {}", card.elapsed)?;
359 writeln!(f, "- **Tokens used**: {}", card.tokens)?;
360 writeln!(f, "- **Token budget**: {}", card.budget)?;
361 writeln!(f)?;
362 writeln!(f, "_Generated by Codewhale `/goal` - {}_", card.now)?;
363 Ok(())
364 }
365
366 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
367 name: "goal",
368 aliases: &["hunt", "mubiao", "狩猎"],
369 usage: "/goal [objective|clear|wounded|resume|declare-hunted|escaped] [budget: N]",
370 description_id: MessageId::CmdGoalDescription,
371 };
372
373 pub(in crate::commands) struct GoalCmd;
374
375 impl RegisterCommand for GoalCmd {
376 fn info() -> &'static CommandInfo {
377 &COMMAND_INFO
378 }
379
380 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
381 hunt(app, arg)
382 }
383 }
384
385 #[cfg(test)]
386 mod tests {
387 use super::*;
388
389 fn create_test_app() -> App {
390 let options = crate::tui::app::TuiOptions {
391 skills_dir: std::path::PathBuf::from("/tmp/test-skills"),
392 ..crate::test_support::test_tui_options(std::path::PathBuf::from("/tmp/test-workspace"))
393 };
394 let config = crate::config::Config::default();
395 App::new(options, &config)
396 }
397
398 #[test]
399 fn test_set_hunt() {
400 let mut app = create_test_app();
401 let result = hunt(&mut app, Some("Fix the login bug"));
402 assert!(result.message.unwrap().contains("Goal set"));
403 assert_eq!(app.hunt.quarry.as_deref(), Some("Fix the login bug"));
404 assert_eq!(
405 app.hunt.verdict.goal_status(),
406 crate::tools::goal::GoalStatus::Active
407 );
408 assert!(matches!(
409 result.action,
410 Some(AppAction::SendMessage(msg)) if msg == "Fix the login bug"
411 ));
412 }
413
414 #[test]
415 fn test_hunt_without_argument_synthesizes_goal_from_context() {
416 // Bare /goal with no active goal is context-dependent: the model
417 // derives the objective from the conversation and sets it via
418 // create_goal — it must not error with a usage demand.
419 let mut app = create_test_app();
420 let result = hunt(&mut app, None);
421 assert!(!result.is_error);
422 let Some(AppAction::SendMessage(message)) = result.action else {
423 panic!("expected SendMessage action");
424 };
425 assert!(message.contains("Synthesize the objective from the conversation"));
426 assert!(message.contains("`create_goal`"));
427 }
428
429 #[test]
430 fn test_hunt_without_argument_shows_state_when_goal_active() {
431 // With an active goal, bare /goal stays a status readout.
432 let mut app = create_test_app();
433 let _ = hunt(&mut app, Some("Fix the login bug"));
434 let result = hunt(&mut app, None);
435 assert!(result.action.is_none());
436 assert!(
437 result
438 .message
439 .as_deref()
440 .unwrap()
441 .contains("Fix the login bug")
442 );
443 }
444
445 #[test]
446 fn test_command_usage_mentions_hunt_verdicts() {
447 assert!(COMMAND_INFO.usage.contains("declare-hunted"));
448 assert!(COMMAND_INFO.usage.contains("wounded"));
449 assert!(COMMAND_INFO.usage.contains("escaped"));
450 }
451
452 #[test]
453 fn test_set_hunt_with_budget() {
454 let mut app = create_test_app();
455 let _ = hunt(&mut app, Some("Refactor auth | budget: 50000"));
456 assert_eq!(app.hunt.quarry.as_deref(), Some("Refactor auth"));
457 assert_eq!(app.hunt.token_budget, Some(50_000));
458 assert!(app.hunt.started_at.is_some());
459 }
460
461 #[test]
462 fn test_set_hunt_rejects_budget_only_objective() {
463 let mut app = create_test_app();
464 app.hunt.quarry = Some("existing objective".to_string());
465 app.hunt.token_budget = Some(10_000);
466
467 let result = hunt(&mut app, Some("budget: 50000"));
468 assert!(result.is_error);
469 assert!(
470 result
471 .message
472 .as_deref()
473 .unwrap_or_default()
474 .contains("/goal <objective>")
475 );
476 assert_eq!(app.hunt.quarry.as_deref(), Some("existing objective"));
477 assert_eq!(app.hunt.token_budget, Some(10_000));
478 }
479
480 #[test]
481 fn test_clear_hunt() {
482 let mut app = create_test_app();
483 app.hunt.quarry = Some("test".to_string());
484 app.hunt.token_budget = Some(100);
485 app.hunt.tokens_used = 5;
486 app.hunt.time_used_seconds = 3;
487 app.hunt.continuation_count = 1;
488 app.hunt.finished_at = Some(std::time::Instant::now());
489 let _ = hunt(&mut app, Some("clear"));
490 assert!(app.hunt.quarry.is_none());
491 assert!(app.hunt.token_budget.is_none());
492 assert_eq!(app.hunt.tokens_used, 0);
493 assert_eq!(app.hunt.time_used_seconds, 0);
494 assert_eq!(app.hunt.continuation_count, 0);
495 assert!(app.hunt.finished_at.is_none());
496 assert_eq!(
497 app.hunt.verdict.goal_status(),
498 crate::tools::goal::GoalStatus::Active
499 );
500 }
501
502 #[test]
503 fn test_verdict_requires_existing_hunt() {
504 let mut app = create_test_app();
505
506 let result = hunt(&mut app, Some("wounded"));
507
508 assert!(result.is_error);
509 assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
510 assert!(app.hunt.quarry.is_none());
511 }
512
513 #[test]
514 fn test_goal_pause_and_resume_update_status() {
515 let mut app = create_test_app();
516 let _ = hunt(&mut app, Some("Finish release prep"));
517
518 let paused = hunt(&mut app, Some("pause"));
519 // Pause now dispatches SetGoalStatus to push Paused into SharedGoalState.
520 assert!(matches!(
521 paused.action,
522 Some(AppAction::SetGoalStatus {
523 status: crate::tools::goal::GoalStatus::Paused,
524 clear: false
525 })
526 ));
527 assert_eq!(app.hunt.verdict, HuntVerdict::Wounded);
528 assert_eq!(
529 app.hunt.verdict.goal_status(),
530 crate::tools::goal::GoalStatus::Paused
531 );
532
533 let resumed = hunt(&mut app, Some("resume"));
534 assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
535 assert_eq!(
536 app.hunt.verdict.goal_status(),
537 crate::tools::goal::GoalStatus::Active
538 );
539 assert!(matches!(
540 resumed.action,
541 Some(AppAction::SendMessage(msg)) if msg == "Finish release prep"
542 ));
543 }
544
545 #[test]
546 fn test_close_hunt_freezes_elapsed_timer() {
547 // close_hunt writes a trophy card under CODEWHALE_HOME/trophies. Isolate
548 // the home dir so sandbox/readonly HOME cannot turn a successful close
549 // into a trophy path error (and so we never touch the real home).
550 let _lock = crate::test_support::lock_test_env();
551 let temp = tempfile::tempdir().expect("isolated CODEWHALE_HOME");
552 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", temp.path());
553
554 let mut app = create_test_app();
555 let _ = hunt(&mut app, Some("Freeze the timer on close"));
556 assert!(
557 app.hunt.finished_at.is_none(),
558 "an active goal must not have a frozen finish time"
559 );
560
561 // Closing the goal as hunted must set finished_at so the sidebar timer
562 // stops ticking instead of reading "completed in {growing elapsed}".
563 let result = hunt(&mut app, Some("done"));
564 assert!(
565 result
566 .message
567 .as_deref()
568 .unwrap_or_default()
569 .contains("Goal hunted. Elapsed:"),
570 "close-out message should report a frozen elapsed; got {:?}",
571 result.message
572 );
573 assert_eq!(app.hunt.verdict, HuntVerdict::Hunted);
574 assert!(
575 app.hunt.finished_at.is_some(),
576 "hunted goal should freeze the elapsed timer"
577 );
578
579 // Resume must re-arm the timer so a resumed goal keeps ticking.
580 let _ = hunt(&mut app, Some("resume"));
581 assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
582 assert!(
583 app.hunt.finished_at.is_none(),
584 "resume should clear the frozen timer"
585 );
586 }
587
588 #[test]
589 fn test_show_hunt_uses_hunt_verdict_label() {
590 let mut app = create_test_app();
591 app.hunt.quarry = Some("Review verifier claim".to_string());
592 app.hunt.verdict = HuntVerdict::Escaped;
593
594 let result = hunt(&mut app, None);
595
596 let message = result.message.as_deref().unwrap_or_default();
597 assert!(message.contains("Goal [ESCAPED]"));
598 assert!(!message.contains("[BLOCKED]"));
599 }
600
601 #[test]
602 fn test_failed_trophy_write_does_not_mutate_verdict() {
603 let mut app = create_test_app();
604 app.hunt.quarry = Some("!!!".to_string());
605 app.hunt.verdict = HuntVerdict::Hunting;
606
607 let result = hunt(&mut app, Some("hunted"));
608
609 assert!(result.is_error);
610 assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
611 assert_eq!(app.hunt.quarry.as_deref(), Some("!!!"));
612 }
613
614 #[test]
615 fn test_escaped_verdict_does_not_write_trophy_card() {
616 let mut app = create_test_app();
617 app.hunt.quarry = Some("!!!".to_string());
618 app.hunt.verdict = HuntVerdict::Hunting;
619
620 let result = hunt(&mut app, Some("escaped"));
621
622 assert!(!result.is_error);
623 assert_eq!(app.hunt.verdict, HuntVerdict::Escaped);
624 assert_eq!(app.hunt.quarry.as_deref(), Some("!!!"));
625 assert!(matches!(
626 result.action,
627 Some(AppAction::SetGoalStatus {
628 status: crate::tools::goal::GoalStatus::Blocked,
629 clear: false
630 })
631 ));
632 }
633
634 #[test]
635 fn test_declare_hunted_alias_uses_trophy_override_path() {
636 let mut app = create_test_app();
637 app.hunt.quarry = Some("!!!".to_string());
638 app.hunt.verdict = HuntVerdict::Hunting;
639
640 let result = hunt(&mut app, Some("declare-hunted"));
641
642 assert!(result.is_error);
643 assert_eq!(app.hunt.verdict, HuntVerdict::Hunting);
644 assert_eq!(app.hunt.quarry.as_deref(), Some("!!!"));
645 assert!(
646 result
647 .message
648 .as_deref()
649 .unwrap_or_default()
650 .contains("Cannot write trophy card")
651 );
652 }
653
654 #[test]
655 fn test_declare_hunted_audit_details_use_hunt_vocabulary() {
656 let mut app = create_test_app();
657 app.hunt.quarry = Some("Verify release gate".to_string());
658 app.hunt.verdict = HuntVerdict::Hunted;
659
660 let details = declare_hunted_audit_details(HuntVerdict::Wounded, &app);
661
662 assert_eq!(details["previous_verdict"], "wounded");
663 assert_eq!(details["current_verdict"], "hunted");
664 assert_eq!(details["has_quarry"], true);
665 }
666
667 #[test]
668 fn test_show_hunt_when_none() {
669 // Bare /goal with no active goal now declares one from context
670 // instead of printing usage.
671 let mut app = create_test_app();
672 let result = hunt(&mut app, None);
673 assert!(
674 result
675 .message
676 .unwrap()
677 .contains("Declaring a goal from the current context")
678 );
679 }
680
681 #[test]
682 fn test_parse_budget() {
683 assert_eq!(
684 parse_hunt_budget("Do a thing | budget: 50000"),
685 ("Do a thing".to_string(), Some(50_000))
686 );
687 assert_eq!(
688 parse_hunt_budget("Simple goal"),
689 ("Simple goal".to_string(), None)
690 );
691 assert_eq!(
692 parse_hunt_budget("Goal budget:1000"),
693 ("Goal".to_string(), Some(1000))
694 );
695 }
696 }
697
697 lines RUST