返回 CodeWhale
user_commands.rs
根目录 / crates / tui / src / commands / user_commands.rs
1 //! User-defined slash commands from `~/.codewhale/commands/<name>.md` and
2 //! workspace-local `<workspace>/.codewhale/commands/<name>.md`.
3 //!
4 //! Users drop `.md` files into a commands directory and the filename
5 //! (without `.md` extension) becomes the default slash-command name. A
6 //! frontmatter `name` may replace it. When invoked, the file contents are sent
7 //! as a user message.
8 //!
9 //! Files may include optional YAML-like frontmatter between `---` markers.
10 //! Supported fields are `name`, `description`, `usage`, `arguments`,
11 //! `argument-hint`, `allowed-tools`, `pausable`, `alias`/`aliases`, and `hidden`.
12 //! Frontmatter is stripped before the command body is sent to the model.
13 //!
14 //! ## Precedence
15 //!
16 //! Workspace-local directories shadow user-global by name:
17 //!
18 //! 1. `<workspace>/.codewhale/commands/` (project-local, highest)
19 //! 2. `<workspace>/.deepseek/commands/` (legacy project-local)
20 //! 3. `<workspace>/.claude/commands/` (Claude Code interop)
21 //! 4. `<workspace>/.cursor/commands/` (Cursor interop)
22 //! 5. `~/.codewhale/commands/` (user-global)
23 //! 6. `~/.deepseek/commands/` (legacy user-global)
24 //!
25 //! ## Permanent Role
26 //!
27 //! This module is the lower-level scanning, frontmatter parsing, and template
28 //! layer for [`super::user_registry::UserCommandRegistry`]. Runtime dispatch
29 //! lives in `user_registry.rs`; this file remains as the shared file I/O and
30 //! parsing boundary documented in `docs/architecture/command-dispatch.md`.
31
32 #[cfg(test)]
33 use std::collections::HashSet;
34 use std::path::{Path, PathBuf};
35
36 #[cfg(test)]
37 use crate::tui::app::{App, AppAction, HuntVerdict};
38
39 #[cfg(test)]
40 use super::CommandResult;
41
42 /// Path to the global user commands directory: `~/.codewhale/commands/`.
43 fn global_commands_dir() -> PathBuf {
44 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
45 home.join(".codewhale").join("commands")
46 }
47
48 fn legacy_global_commands_dir() -> PathBuf {
49 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
50 home.join(".deepseek").join("commands")
51 }
52
53 /// Return all candidate commands directories in precedence order.
54 pub(crate) fn commands_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
55 let mut dirs = Vec::new();
56 if let Some(ws) = workspace {
57 dirs.push(ws.join(".codewhale").join("commands"));
58 dirs.push(ws.join(".deepseek").join("commands"));
59 dirs.push(ws.join(".claude").join("commands"));
60 dirs.push(ws.join(".cursor").join("commands"));
61 }
62 dirs.push(global_commands_dir());
63 dirs.push(legacy_global_commands_dir());
64 dirs
65 }
66
67 /// Saved-workflow slash commands (#4121 packaging): `*.workflow.js` files
68 /// under these directories become `/name` commands that start the workflow
69 /// through the `workflow` tool with the slash arguments forwarded as the
70 /// run's `args`. Workspace definitions shadow the user-global store.
71 pub(crate) fn workflow_dirs(workspace: Option<&Path>) -> Vec<PathBuf> {
72 let mut dirs = Vec::new();
73 if let Some(ws) = workspace {
74 dirs.push(ws.join(".codewhale").join("workflows"));
75 }
76 let home = crate::config::effective_home_dir().unwrap_or_else(|| PathBuf::from("~"));
77 dirs.push(home.join(".codewhale").join("workflows"));
78 dirs
79 }
80
81 /// Canonical saved-workflow source suffix.
82 pub(crate) const WORKFLOW_SOURCE_SUFFIX: &str = ".workflow.js";
83
84 /// Scan one workflow directory and synthesize a markdown command definition
85 /// per `*.workflow.js` file. Returns `(name, content, source_path)` tuples;
86 /// unreadable entries are skipped.
87 pub(crate) fn load_workflow_commands_from_dir(dir: &Path) -> Vec<(String, String, PathBuf)> {
88 let mut commands = Vec::new();
89 if !dir.is_dir() {
90 return commands;
91 }
92 let Ok(entries) = std::fs::read_dir(dir) else {
93 return commands;
94 };
95 for entry in entries.flatten() {
96 let path = entry.path();
97 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
98 continue;
99 };
100 let Some(stem) = file_name.strip_suffix(WORKFLOW_SOURCE_SUFFIX) else {
101 continue;
102 };
103 let name = stem.to_lowercase();
104 if name.is_empty() {
105 continue;
106 }
107 let description = std::fs::read_to_string(&path)
108 .ok()
109 .and_then(|source| workflow_headline(&source))
110 .unwrap_or_else(|| format!("Run the saved workflow {name}"));
111 let content = synthesize_workflow_command(&name, &description, &path);
112 commands.push((name, content, path));
113 }
114 commands.sort_by(|a, b| a.0.cmp(&b.0));
115 commands
116 }
117
118 /// First `//` comment line of a workflow source, used as the command
119 /// description in palettes and help.
120 fn workflow_headline(source: &str) -> Option<String> {
121 source.lines().find_map(|line| {
122 let comment = line.trim().strip_prefix("//")?.trim();
123 (!comment.is_empty()).then(|| comment.split_whitespace().collect::<Vec<_>>().join(" "))
124 })
125 }
126
127 fn synthesize_workflow_command(name: &str, description: &str, path: &Path) -> String {
128 // Frontmatter values must stay single-line; the headline is already one
129 // line but defend against pathological sources.
130 let description = description.replace(['\r', '\n'], " ");
131 format!(
132 "---\ndescription: {description}\nusage: /{name} [args...]\narguments: forwarded to the workflow run's args\n---\nStart the saved workflow `{name}` now: call the `workflow` tool with action=\"start\", source_path=\"{path}\", and args built from this argument text: $ARGUMENTS\nIf the argument text is empty, start the run without args. Report the run_id, monitor with the workflow tool's status action, and when the run settles present its receipt summary (status, phases, failures, artifacts). The durable report lands under .codewhale/reports/<run_id>.md.",
133 path = path.display(),
134 )
135 }
136
137 /// Scan a single commands directory for `.md` files and return
138 /// `(name, content)` pairs. Errors are silently skipped.
139 pub(crate) fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> {
140 let mut commands: Vec<(String, String)> = Vec::new();
141
142 if !dir.is_dir() {
143 return commands;
144 }
145
146 let entries = match std::fs::read_dir(dir) {
147 Ok(entries) => entries,
148 Err(_) => return commands,
149 };
150
151 for entry in entries.flatten() {
152 let path = entry.path();
153 if path.extension().and_then(|e| e.to_str()) != Some("md") {
154 continue;
155 }
156 let stem = match path.file_stem().and_then(|s| s.to_str()) {
157 Some(stem) => stem.to_lowercase(),
158 None => continue,
159 };
160 let content = match std::fs::read_to_string(&path) {
161 Ok(c) => c,
162 Err(_) => continue,
163 };
164 commands.push((stem, content));
165 }
166
167 commands
168 }
169
170 /// Scan every candidate commands directory and return merged
171 /// `(name, content)` pairs. Workspace-local directories shadow
172 /// user-global by name — the first occurrence of a name wins.
173 ///
174 /// Pass `None` for the workspace to scan only the global directory
175 /// (backward-compatible with callers that don't have workspace context).
176 #[cfg(test)]
177 pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> {
178 let mut seen: HashSet<String> = HashSet::new();
179 let mut commands: Vec<(String, String)> = Vec::new();
180
181 for dir in commands_dirs(workspace) {
182 for (name, content) in load_commands_from_dir(&dir) {
183 if seen.insert(name.clone()) {
184 commands.push((name, content));
185 }
186 }
187 }
188
189 // Sort by name for deterministic ordering.
190 commands.sort_by(|a, b| a.0.cmp(&b.0));
191 commands
192 }
193
194 pub(crate) fn parse_frontmatter(content: &str) -> (Vec<(String, String)>, &str) {
195 let Some(first_line_end) = content.find('\n') else {
196 return (Vec::new(), content);
197 };
198 let first = content[..first_line_end].trim_end_matches('\r');
199
200 if first.trim().chars().all(|ch| ch == '-') && first.trim().len() >= 3 {
201 let mut metadata = Vec::new();
202 let mut offset = first_line_end + 1;
203 let mut unclosed_body_start = None;
204 for raw_line in content[offset..].split_inclusive('\n') {
205 let line_start = offset;
206 let line = raw_line.trim_end_matches(['\r', '\n']);
207 offset += raw_line.len();
208 let trimmed = line.trim();
209 if unclosed_body_start.is_none() {
210 if trimmed.chars().all(|ch| ch == '-') && trimmed.len() >= 3 {
211 let body = content[offset..].trim_start_matches(['\r', '\n']);
212 return (metadata, body);
213 }
214 if let Some((key, value)) = line.split_once(':') {
215 let key = key.trim().to_ascii_lowercase();
216 let raw_value = value.trim();
217 let value = if key == "allowed-tools" {
218 raw_value.to_string()
219 } else {
220 strip_matched_quotes(raw_value).to_string()
221 };
222 if !key.is_empty() {
223 metadata.push((key, value));
224 }
225 } else if !trimmed.is_empty() {
226 unclosed_body_start = Some(line_start);
227 }
228 }
229 }
230 let body_start = unclosed_body_start.unwrap_or(content.len());
231 let body = content[body_start..].trim_start_matches(['\r', '\n']);
232 return (metadata, body);
233 }
234
235 (Vec::new(), content)
236 }
237
238 fn strip_matched_quotes(value: &str) -> &str {
239 if let Some(stripped) = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
240 return stripped;
241 }
242 if let Some(stripped) = value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')) {
243 return stripped;
244 }
245 value
246 }
247
248 pub(crate) fn parse_allowed_tools(value: &str) -> Vec<String> {
249 value
250 .split(',')
251 .map(|tool| {
252 strip_matched_quotes(tool.trim())
253 .trim()
254 .to_ascii_lowercase()
255 })
256 .filter(|tool| !tool.is_empty())
257 .collect()
258 }
259
260 /// Check if the input matches a user-defined command and return the
261 /// content as a `SendMessage` action.
262 ///
263 /// The `input` should be the full command string including the `/`
264 /// prefix (e.g. `/mycmd` or `/mycmd with args`). Only exact matches
265 /// on the command name are considered (no partial/alias matching).
266 /// Substitute $1, $2, $ARGUMENTS placeholders in a command template.
267 pub(crate) fn apply_template(template: &str, args: &str) -> String {
268 let positional: Vec<&str> = args.split_whitespace().collect();
269 let mut result = template.replace("$ARGUMENTS", args);
270 for (i, arg) in positional.iter().enumerate() {
271 result = result.replace(&format!("${}", i + 1), arg);
272 }
273 result
274 }
275
276 #[cfg(test)]
277 pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option<CommandResult> {
278 let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
279 let command = parts[0].to_lowercase();
280 let command = command.strip_prefix('/').unwrap_or(&command);
281 let args = parts.get(1).copied().unwrap_or("").trim();
282
283 let user_commands = load_user_commands(Some(&app.workspace));
284
285 for (name, content) in &user_commands {
286 if name == command {
287 let (metadata, body) = parse_frontmatter(content);
288 app.hunt.quarry = None;
289 app.hunt.started_at = None;
290 app.hunt.verdict = HuntVerdict::Hunting;
291 app.hunt.token_budget = None;
292 app.hunt.tokens_used = 0;
293 app.hunt.time_used_seconds = 0;
294 app.hunt.continuation_count = 0;
295 app.active_allowed_tools = None;
296 app.pausable = false;
297 app.paused = false;
298 app.paused_quarry = None;
299 // Clear todos and plan state from the previous command so they
300 // don't bleed into the next one. Both are behind the same locks
301 // the sidebar reads; a contended/poisoned lock is logged and
302 // skipped rather than blocking dispatch.
303 if let Ok(mut todos) = app.todos.try_lock() {
304 todos.clear();
305 } else {
306 tracing::warn!(target: "commands", "todos lock contended or poisoned — previous todos not cleared");
307 }
308 if let Ok(mut plan) = app.plan_state.try_lock() {
309 *plan = crate::tools::plan::PlanState::default();
310 } else {
311 tracing::warn!(target: "commands", "plan_state lock contended or poisoned — previous plan not cleared");
312 }
313 for (key, value) in &metadata {
314 match key.as_str() {
315 "description" => {
316 app.hunt.quarry = Some(value.clone());
317 app.hunt.started_at = Some(std::time::Instant::now());
318 }
319 "allowed-tools" => {
320 app.active_allowed_tools = Some(parse_allowed_tools(value));
321 }
322 "pausable" => {
323 app.pausable = value.trim().eq_ignore_ascii_case("true");
324 }
325 _ => {}
326 }
327 }
328 let message = apply_template(body, args);
329 return Some(CommandResult::action(AppAction::SendMessage(message)));
330 }
331 }
332
333 None
334 }
335
336 #[cfg(test)]
337 mod tests {
338 use super::*;
339 use tempfile::TempDir;
340
341 #[test]
342 fn test_global_commands_dir_contains_codewhale_commands() {
343 let dir = global_commands_dir();
344 let parts: Vec<_> = dir
345 .components()
346 .filter_map(|component| component.as_os_str().to_str())
347 .collect();
348 assert!(
349 parts
350 .windows(2)
351 .any(|pair| pair == [".codewhale", "commands"]),
352 "expected .codewhale/commands components in path, got: {}",
353 dir.display()
354 );
355 }
356
357 #[test]
358 fn test_load_user_commands_when_no_dir_exists() {
359 let cmds = load_user_commands(None);
360 // Should not panic; returns empty vec when no directories exist.
361 assert!(cmds.is_empty() || !cmds.is_empty());
362 }
363
364 #[test]
365 fn test_try_dispatch_nonexistent_command() {
366 use crate::config::Config;
367 use crate::tui::app::TuiOptions;
368
369 let options = TuiOptions {
370 ..crate::test_support::test_tui_options(PathBuf::from("."))
371 };
372 let mut app = App::new(options, &Config::default());
373 let result = try_dispatch_user_command(&mut app, "/nonexistent-thing-12345");
374 assert!(result.is_none());
375 }
376
377 // ── Workspace-local commands tests ─────────────────────────────────
378
379 fn write_command(dir: &Path, name: &str, body: &str) {
380 std::fs::create_dir_all(dir).unwrap();
381 std::fs::write(dir.join(format!("{name}.md")), body).unwrap();
382 }
383
384 fn test_options(workspace: PathBuf) -> crate::tui::app::TuiOptions {
385 crate::tui::app::TuiOptions {
386 ..crate::test_support::test_tui_options(workspace)
387 }
388 }
389
390 #[test]
391 fn load_user_commands_scans_workspace_local_dir() {
392 let tmp = TempDir::new().unwrap();
393 let ws = tmp.path();
394 let cmds_dir = ws.join(".codewhale").join("commands");
395 write_command(&cmds_dir, "hello", "echo hi");
396
397 let cmds = load_user_commands(Some(ws));
398 let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
399 assert!(
400 names.contains(&"hello"),
401 "expected 'hello' in workspace-local commands: {names:?}"
402 );
403 }
404
405 #[test]
406 fn load_user_commands_scans_claude_and_cursor_dirs() {
407 let tmp = TempDir::new().unwrap();
408 let ws = tmp.path();
409 write_command(
410 &ws.join(".claude").join("commands"),
411 "claude-cmd",
412 "claude body",
413 );
414 write_command(
415 &ws.join(".cursor").join("commands"),
416 "cursor-cmd",
417 "cursor body",
418 );
419
420 let cmds = load_user_commands(Some(ws));
421 let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect();
422 assert!(
423 names.contains(&"claude-cmd"),
424 "expected 'claude-cmd': {names:?}"
425 );
426 assert!(
427 names.contains(&"cursor-cmd"),
428 "expected 'cursor-cmd': {names:?}"
429 );
430 }
431
432 #[test]
433 fn workspace_local_shadows_global_by_name() {
434 let tmp = TempDir::new().unwrap();
435 let ws = tmp.path();
436
437 // Workspace-local version
438 write_command(
439 &ws.join(".codewhale").join("commands"),
440 "shared",
441 "workspace version",
442 );
443 // Global version — simulate by putting it in a "global" temp dir.
444 // Paths resolve via effective_home_dir (HOME/USERPROFILE-aware). We test the
445 // first-match-wins semantics by putting the same name in both
446 // workspace-scanned dirs. The first dir in precedence order wins.
447 write_command(
448 &ws.join(".claude").join("commands"),
449 "shared",
450 "claude version",
451 );
452
453 let cmds = load_user_commands(Some(ws));
454 let shared = cmds
455 .iter()
456 .find(|(n, _)| n == "shared")
457 .expect("shared present");
458 assert_eq!(
459 shared.1, "workspace version",
460 "workspace-local (.codewhale) must shadow later dirs"
461 );
462 }
463
464 #[test]
465 fn load_user_commands_without_workspace_falls_back_to_global_only() {
466 // When no workspace is passed, only global command directories are
467 // scanned. On test machines these often don't exist, so we just
468 // verify we don't panic.
469 let cmds = load_user_commands(None);
470 // This should not panic; can be empty or have user's real commands.
471 let _ = cmds;
472 }
473
474 #[test]
475 fn try_dispatch_uses_workspace_local_command() {
476 use crate::config::Config;
477 use crate::tui::app::TuiOptions;
478
479 let tmp = TempDir::new().unwrap();
480 let ws = tmp.path().to_path_buf();
481 write_command(
482 &ws.join(".deepseek").join("commands"),
483 "hello",
484 "Hello, $ARGUMENTS!",
485 );
486
487 let options = TuiOptions {
488 ..crate::test_support::test_tui_options(ws.clone())
489 };
490 let mut app = App::new(options, &Config::default());
491 let result = try_dispatch_user_command(&mut app, "/hello world");
492 assert!(result.is_some());
493 let cmd_result = result.unwrap();
494 match cmd_result.action {
495 Some(AppAction::SendMessage(msg)) => {
496 assert!(msg.contains("Hello, world!"), "got: {msg}");
497 }
498 other => panic!("expected SendMessage action, got: {other:?}"),
499 }
500 }
501
502 #[test]
503 fn frontmatter_is_stripped_before_dispatch() {
504 use crate::config::Config;
505
506 let tmp = TempDir::new().unwrap();
507 let ws = tmp.path().to_path_buf();
508 write_command(
509 &ws.join(".deepseek").join("commands"),
510 "secure",
511 "---\ndescription: Secure scan\nallowed-tools: Bash, Read\n---\nRun $ARGUMENTS",
512 );
513
514 let mut app = App::new(test_options(ws), &Config::default());
515 let result = try_dispatch_user_command(&mut app, "/secure checks").unwrap();
516 match result.action {
517 Some(AppAction::SendMessage(msg)) => assert_eq!(msg, "Run checks"),
518 other => panic!("expected SendMessage action, got: {other:?}"),
519 }
520 }
521
522 #[test]
523 fn review_regression_unclosed_frontmatter_keeps_metadata_and_strips_header() {
524 let (metadata, body) = parse_frontmatter(
525 "---\ndescription: Broken command\nallowed-tools: Bash\nRun the safe body",
526 );
527
528 assert_eq!(
529 metadata,
530 vec![
531 ("description".to_string(), "Broken command".to_string()),
532 ("allowed-tools".to_string(), "Bash".to_string())
533 ]
534 );
535 assert_eq!(body, "Run the safe body");
536 }
537
538 #[test]
539 fn review_regression_unclosed_frontmatter_without_metadata_strips_header() {
540 let (metadata, body) =
541 parse_frontmatter("---\nRun the command body without a closing delimiter");
542
543 assert!(metadata.is_empty());
544 assert_eq!(body, "Run the command body without a closing delimiter");
545 }
546
547 #[test]
548 fn review_regression_frontmatter_strips_only_matched_quote_pairs() {
549 let (metadata, body) = parse_frontmatter("---\ndescription: 'Read\"\n---\nrun");
550
551 assert_eq!(
552 metadata,
553 vec![("description".to_string(), "'Read\"".to_string())]
554 );
555 assert_eq!(body, "run");
556 }
557
558 #[test]
559 fn allowed_tools_frontmatter_sets_app_state() {
560 use crate::config::Config;
561
562 let tmp = TempDir::new().unwrap();
563 let ws = tmp.path().to_path_buf();
564 write_command(
565 &ws.join(".deepseek").join("commands"),
566 "secure",
567 "---\nallowed-tools: Bash, Grep\n---\nrun tests",
568 );
569
570 let mut app = App::new(test_options(ws), &Config::default());
571 let _ = try_dispatch_user_command(&mut app, "/secure").unwrap();
572 assert_eq!(
573 app.active_allowed_tools,
574 Some(vec!["bash".to_string(), "grep".to_string()])
575 );
576 }
577
578 #[test]
579 fn pausable_frontmatter_sets_app_state_without_worktree_mutation() {
580 use crate::config::Config;
581
582 if std::process::Command::new("git")
583 .arg("--version")
584 .output()
585 .is_err()
586 {
587 return;
588 }
589
590 let tmp = TempDir::new().unwrap();
591 let ws = tmp.path().to_path_buf();
592 let init = std::process::Command::new("git")
593 .args(["-C", ws.to_str().unwrap(), "init"])
594 .output()
595 .expect("git init");
596 assert!(
597 init.status.success(),
598 "git init failed: {}",
599 String::from_utf8_lossy(&init.stderr)
600 );
601 std::fs::write(ws.join("user-work.txt"), "untracked user work").unwrap();
602 write_command(
603 &ws.join(".codewhale").join("commands"),
604 "pause-scan",
605 "---\ndescription: Scan repos\npausable: true\n---\nscan",
606 );
607
608 let mut app = App::new(test_options(ws.clone()), &Config::default());
609 let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap();
610
611 assert!(app.pausable);
612 assert!(!app.paused);
613 assert!(app.paused_quarry.is_none());
614 assert!(ws.join("user-work.txt").exists());
615 let stash = std::process::Command::new("git")
616 .args(["-C", ws.to_str().unwrap(), "stash", "list"])
617 .output()
618 .expect("git stash list");
619 assert!(
620 stash.status.success(),
621 "git stash list failed: {}",
622 String::from_utf8_lossy(&stash.stderr)
623 );
624 assert!(
625 String::from_utf8_lossy(&stash.stdout).trim().is_empty(),
626 "pausable dispatch must not create git stash entries"
627 );
628 }
629
630 #[test]
631 fn new_user_command_clears_stale_paused_state() {
632 use crate::config::Config;
633
634 let tmp = TempDir::new().unwrap();
635 let ws = tmp.path().to_path_buf();
636 let commands_dir = ws.join(".codewhale").join("commands");
637 write_command(
638 &commands_dir,
639 "pause-scan",
640 "---\ndescription: Scan repos\npausable: true\n---\nscan",
641 );
642 write_command(&commands_dir, "plain", "plain command");
643
644 let mut app = App::new(test_options(ws), &Config::default());
645 let _ = try_dispatch_user_command(&mut app, "/pause-scan").unwrap();
646 app.paused = true;
647 app.paused_quarry = Some("Scan repos".to_string());
648
649 let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
650
651 assert!(!app.pausable);
652 assert!(!app.paused);
653 assert!(app.paused_quarry.is_none());
654 }
655
656 #[test]
657 fn new_user_command_clears_previous_todos_and_plan() {
658 use crate::config::Config;
659 use crate::tools::plan::UpdatePlanArgs;
660 use crate::tools::todo::TodoStatus;
661
662 let tmp = TempDir::new().unwrap();
663 let ws = tmp.path().to_path_buf();
664 let commands_dir = ws.join(".codewhale").join("commands");
665 write_command(&commands_dir, "first", "first command body");
666 write_command(&commands_dir, "second", "second command body");
667
668 let mut app = App::new(test_options(ws), &Config::default());
669
670 // Seed the state a previous command would leave behind: a non-empty
671 // todo list and a non-empty plan. These should NOT bleed into the
672 // next command. The shared lists are tokio async mutexes, so seed and
673 // observe through `try_lock` (the same sync path dispatch uses).
674 {
675 let mut todos = app.todos.try_lock().expect("todos lock");
676 todos.add(
677 "leftover task from first command".to_string(),
678 TodoStatus::Pending,
679 );
680 }
681 {
682 let mut plan = app.plan_state.try_lock().expect("plan_state lock");
683 plan.update(UpdatePlanArgs {
684 title: Some("leftover plan".to_string()),
685 objective: Some("old goal".to_string()),
686 ..Default::default()
687 });
688 }
689
690 // Dispatch a fresh command — dispatch must reset both.
691 let _ = try_dispatch_user_command(&mut app, "/second").unwrap();
692
693 assert!(
694 app.todos
695 .try_lock()
696 .expect("todos lock")
697 .snapshot()
698 .items
699 .is_empty(),
700 "previous command's todos must be cleared on new command dispatch"
701 );
702 assert!(
703 app.plan_state
704 .try_lock()
705 .expect("plan_state lock")
706 .snapshot()
707 .is_empty(),
708 "previous command's plan must be cleared on new command dispatch"
709 );
710 }
711
712 #[test]
713 fn review_regression_empty_allowed_tools_blocks_all_tools() {
714 use crate::config::Config;
715
716 let tmp = TempDir::new().unwrap();
717 let ws = tmp.path().to_path_buf();
718 write_command(
719 &ws.join(".deepseek").join("commands"),
720 "locked",
721 "---\nallowed-tools: \"\"\n---\nrun nothing",
722 );
723
724 let mut app = App::new(test_options(ws), &Config::default());
725 let _ = try_dispatch_user_command(&mut app, "/locked").unwrap();
726 assert_eq!(app.active_allowed_tools, Some(Vec::new()));
727 }
728
729 #[test]
730 fn review_regression_allowed_tools_accepts_per_item_quotes() {
731 use crate::config::Config;
732
733 let tmp = TempDir::new().unwrap();
734 let ws = tmp.path().to_path_buf();
735 write_command(
736 &ws.join(".deepseek").join("commands"),
737 "quoted",
738 "---\nallowed-tools: \"exec_shell\", 'read_file'\n---\nrun quoted tools",
739 );
740
741 let mut app = App::new(test_options(ws), &Config::default());
742 let _ = try_dispatch_user_command(&mut app, "/quoted").unwrap();
743 assert_eq!(
744 app.active_allowed_tools,
745 Some(vec!["exec_shell".to_string(), "read_file".to_string()])
746 );
747 }
748
749 #[test]
750 fn review_regression_dispatch_without_frontmatter_resets_previous_command_state() {
751 use crate::config::Config;
752
753 let tmp = TempDir::new().unwrap();
754 let ws = tmp.path().to_path_buf();
755 let commands_dir = ws.join(".deepseek").join("commands");
756 write_command(
757 &commands_dir,
758 "described",
759 "---\ndescription: Scan repos\nallowed-tools: Bash\n---\nscan",
760 );
761 write_command(&commands_dir, "plain", "plain command");
762
763 let mut app = App::new(test_options(ws), &Config::default());
764 let _ = try_dispatch_user_command(&mut app, "/described").unwrap();
765 assert_eq!(app.hunt.quarry.as_deref(), Some("Scan repos"));
766 assert!(app.hunt.started_at.is_some());
767 assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting);
768 assert_eq!(app.hunt.token_budget, None);
769 assert_eq!(app.active_allowed_tools, Some(vec!["bash".to_string()]));
770
771 app.hunt.verdict = crate::tui::app::HuntVerdict::Escaped;
772 app.hunt.token_budget = Some(42);
773 app.hunt.tokens_used = 100;
774 app.hunt.time_used_seconds = 5;
775 app.hunt.continuation_count = 1;
776 let _ = try_dispatch_user_command(&mut app, "/plain").unwrap();
777 assert_eq!(app.hunt.quarry, None);
778 assert_eq!(app.hunt.started_at, None);
779 assert_eq!(app.hunt.verdict, crate::tui::app::HuntVerdict::Hunting);
780 assert_eq!(app.hunt.token_budget, None);
781 assert_eq!(app.hunt.tokens_used, 0);
782 assert_eq!(app.hunt.time_used_seconds, 0);
783 assert_eq!(app.hunt.continuation_count, 0);
784 assert_eq!(app.active_allowed_tools, None);
785 }
786
787 #[test]
788 fn description_frontmatter_sets_work_objective_and_autocomplete_description() {
789 use crate::config::Config;
790
791 let tmp = TempDir::new().unwrap();
792 let ws = tmp.path().to_path_buf();
793 write_command(
794 &ws.join(".deepseek").join("commands"),
795 "git-scan",
796 "---\ndescription: Scan nested git repositories\nargument-hint: <root>\n---\nscan",
797 );
798
799 let mut app = App::new(test_options(ws.clone()), &Config::default());
800 let _ = try_dispatch_user_command(&mut app, "/git-scan").unwrap();
801 assert_eq!(
802 app.hunt.quarry.as_deref(),
803 Some("Scan nested git repositories")
804 );
805 let commands = load_user_commands(Some(&ws));
806 let (_, content) = commands
807 .iter()
808 .find(|(name, _)| name == "git-scan")
809 .expect("git-scan command should load");
810 let (metadata, _) = parse_frontmatter(content);
811 assert!(metadata.contains(&(
812 "description".to_string(),
813 "Scan nested git repositories".to_string()
814 )));
815 assert!(metadata.contains(&("argument-hint".to_string(), "<root>".to_string())));
816 }
817
818 #[test]
819 fn parser_preserves_layer_5_1_frontmatter_fields() {
820 let (metadata, body) = parse_frontmatter(
821 "---\nname: inspect\ndescription: Inspect a target\nusage: /inspect <path>\narguments: <path>\nhidden: false\nallowed-tools: Read_File, Grep_Files\n---\ninspect $ARGUMENTS",
822 );
823
824 assert!(metadata.contains(&("name".to_string(), "inspect".to_string())));
825 assert!(metadata.contains(&("description".to_string(), "Inspect a target".to_string())));
826 assert!(metadata.contains(&("usage".to_string(), "/inspect <path>".to_string())));
827 assert!(metadata.contains(&("arguments".to_string(), "<path>".to_string())));
828 assert!(metadata.contains(&("hidden".to_string(), "false".to_string())));
829 assert!(metadata.contains(&(
830 "allowed-tools".to_string(),
831 "Read_File, Grep_Files".to_string()
832 )));
833 assert_eq!(body, "inspect $ARGUMENTS");
834 }
835 }
836
836 lines RUST