Skip to main content

oxicode/tui_vt/slash/
file_commands.rs

1//! User-defined slash commands loaded from `.md` files.
2//!
3//! Each command file lives in `.oxicode/commands/<name>.md` (project) or
4//! `~/.oxicode/commands/<name>.md` (user). The filename stem (minus `.md`)
5//! is the command name. Optional YAML-like frontmatter provides `description`
6//! and `aliases`. The body is a prompt template expanded at dispatch time.
7
8use std::collections::HashSet;
9use std::path::Path;
10
11/// A user-defined slash command loaded from a `.md` file.
12#[derive(Debug, Clone)]
13pub struct FileCommand {
14    /// Command name (filename stem, no `.md`, no leading `/`).
15    pub name: String,
16    /// Description from frontmatter, or first body line.
17    pub description: String,
18    /// Alternative names from frontmatter `aliases` (comma-separated).
19    pub aliases: Vec<String>,
20    /// Template body (frontmatter stripped). Contains `$ARGUMENTS`, `$@`,
21    /// `$1`, `$2`, ... placeholders expanded at dispatch time.
22    body: String,
23}
24
25impl FileCommand {
26    /// Parse a command from its filename stem and file content.
27    ///
28    /// Frontmatter is optional YAML-like `key: value` lines between `---`
29    /// delimiters. Supported keys: `description`, `aliases`.
30    /// Without frontmatter, the first non-empty body line is the description.
31    pub fn parse(name: &str, content: &str) -> Self {
32        let (front, body) = split_frontmatter(content);
33        let mut description = String::new();
34        let mut aliases = Vec::new();
35
36        if let Some(ref front) = front {
37            for line in front.lines() {
38                if let Some(val) = line.strip_prefix("description:") {
39                    description = val.trim().to_string();
40                } else if let Some(val) = line.strip_prefix("aliases:") {
41                    aliases = val
42                        .split(',')
43                        .map(|s| s.trim().to_string())
44                        .filter(|s| !s.is_empty())
45                        .collect();
46                }
47            }
48        }
49
50        // Fallback: first non-empty body line as description.
51        if description.is_empty() {
52            description = body
53                .lines()
54                .find(|l| !l.trim().is_empty())
55                .unwrap_or("")
56                .trim()
57                .chars()
58                .take(60)
59                .collect();
60            if body.lines().any(|l| !l.trim().is_empty()) && description.len() == 60 {
61                description.push_str("...");
62            }
63        }
64
65        FileCommand {
66            name: name.to_string(),
67            description,
68            aliases,
69            body,
70        }
71    }
72
73    /// True if `token` matches the canonical name or any alias.
74    pub fn matches(&self, token: &str) -> bool {
75        self.name == token || self.aliases.iter().any(|a| a == token)
76    }
77
78    /// Expand the template body with `args`, replacing `$ARGUMENTS`/`$@`
79    /// (full arg string) and `$1`, `$2`, ... (positional).
80    pub fn expand(&self, args: &str) -> String {
81        expand_template(&self.body, args)
82    }
83}
84
85/// Split `---\n...\n---` frontmatter from the body.
86/// Returns `(frontmatter_without_delimiters, body_without_frontmatter)`.
87fn split_frontmatter(content: &str) -> (Option<String>, String) {
88    if let Some(body) = content.strip_prefix("---\n")
89        && let Some(end) = body.find("\n---")
90    {
91        let front = body[..end].to_string();
92        let rest = body[end + 4..].trim_start_matches('\n').to_string();
93        return (Some(front), rest);
94    }
95    (None, content.trim_start_matches('\n').to_string())
96}
97
98/// Expand template placeholders: `$ARGUMENTS`/`$@` (all args), `$1`, `$2`, ...
99///
100/// Scans the template body left-to-right in a single pass and never re-scans
101/// substituted content, so user argument text containing `$N`, `$@`, or
102/// `$ARGUMENTS` is emitted verbatim.
103pub fn expand_template(body: &str, args: &str) -> String {
104    let positional = split_args(args);
105    let mut out = String::with_capacity(body.len());
106    let bytes = body.as_bytes();
107    let mut i = 0;
108    while i < bytes.len() {
109        if bytes[i] == b'$' {
110            // Peek at the slice after `$` so `strip_prefix` keeps the remainder
111            // available for further inspection.
112            let rest = &body[i + 1..];
113            if rest.starts_with("ARGUMENTS") {
114                out.push_str(args);
115                i += 1 + "ARGUMENTS".len();
116                continue;
117            }
118            if rest.starts_with('@') {
119                out.push_str(args);
120                i += 2;
121                continue;
122            }
123            // Count consecutive ASCII digits immediately after `$`.
124            let digit_len = rest
125                .as_bytes()
126                .iter()
127                .take_while(|b| b.is_ascii_digit())
128                .count();
129            if digit_len > 0 {
130                // `$0` is never a positional (positionals are 1-indexed); emit
131                // it literally so a template that mentions `$0` is preserved.
132                if digit_len == 1 && rest.as_bytes()[0] == b'0' {
133                    out.push('$');
134                    out.push('0');
135                    i += 2;
136                    continue;
137                }
138                let digits = &rest[..digit_len];
139                match digits.parse::<usize>() {
140                    Ok(idx) if idx >= 1 && idx <= positional.len() => {
141                        out.push_str(positional[idx - 1].as_str());
142                    }
143                    Ok(_) => {
144                        // Missing positional becomes empty.
145                    }
146                    Err(_) => {
147                        // Unreachable: digits are all ASCII so parse cannot fail.
148                        out.push('$');
149                        for c in digits.chars() {
150                            out.push(c);
151                        }
152                    }
153                }
154                i += 1 + digit_len;
155                continue;
156            }
157            // `$` not followed by a known placeholder: emit literally.
158            out.push('$');
159            i += 1;
160            continue;
161        }
162        // Copy one full UTF-8 codepoint starting at byte `i`. `i` always
163        // points at a char boundary because we only advance by full codepoint
164        // lengths in the non-`$` branch and by ASCII lengths in the `$` branch.
165        let ch = body[i..].chars().next().unwrap_or('\0');
166        if ch == '\0' {
167            break;
168        }
169        out.push(ch);
170        i += ch.len_utf8();
171    }
172    out
173}
174
175/// Simple quote-aware argument split (omp `parseCommandArgs` parity).
176/// Supports `'single'` and `"double"` quoting. No backslash escaping.
177fn split_args(args: &str) -> Vec<String> {
178    let mut result = Vec::new();
179    let mut current = String::new();
180    let mut in_quote: Option<char> = None;
181    for ch in args.chars() {
182        match in_quote {
183            Some(q) => {
184                if ch == q {
185                    in_quote = None;
186                } else {
187                    current.push(ch);
188                }
189            }
190            None => {
191                if ch == '\'' || ch == '"' {
192                    in_quote = Some(ch);
193                } else if ch.is_whitespace() {
194                    if !current.is_empty() {
195                        result.push(std::mem::take(&mut current));
196                    }
197                } else {
198                    current.push(ch);
199                }
200            }
201        }
202    }
203    if !current.is_empty() {
204        result.push(current);
205    }
206    result
207}
208
209/// Scan one commands directory. Appends discovered commands to `out`,
210/// skipping names already in `seen` (first-wins collision resolution).
211fn scan_dir(dir: &Path, out: &mut Vec<FileCommand>, seen: &mut HashSet<String>) {
212    let entries = match std::fs::read_dir(dir) {
213        Ok(e) => e,
214        Err(_) => return,
215    };
216    let mut entries: Vec<_> = entries.flatten().collect();
217    entries.sort_by_key(|entry| {
218        entry
219            .path()
220            .file_stem()
221            .and_then(|stem| stem.to_str())
222            .map(str::to_owned)
223    });
224    for entry in entries {
225        let path = entry.path();
226        // Only non-hidden `.md` files.
227        if path.extension().is_none_or(|ext| ext != "md") {
228            continue;
229        }
230        let stem = match path.file_stem().and_then(|s| s.to_str()) {
231            Some(s) => s.to_string(),
232            None => continue,
233        };
234        if stem.starts_with('.') {
235            continue;
236        }
237        if seen.contains(&stem) {
238            continue;
239        }
240        let content = match std::fs::read_to_string(&path) {
241            Ok(c) => c,
242            Err(_) => continue,
243        };
244        seen.insert(stem.clone());
245        out.push(FileCommand::parse(&stem, &content));
246    }
247}
248
249/// Load all file-based commands from project (`.oxicode/commands/`) and user
250/// (canonical home `commands/`, with legacy `~/.oxicode/commands/` read-only
251/// fallback) directories. Project entries take precedence on name collision
252/// (scanned first).
253pub fn load_file_commands(cwd: &Path) -> Vec<FileCommand> {
254    let mut commands = Vec::new();
255    let mut seen: HashSet<String> = HashSet::new();
256
257    // 1. Project: .oxicode/commands/ (higher precedence)
258    let project_dir = cwd.join(".oxicode").join("commands");
259    scan_dir(&project_dir, &mut commands, &mut seen);
260
261    // 2. User: canonical commands dir (legacy read-only fallback).
262    if let Some(user_dir) = oxicode_catalog::oxi_home::read_path(Path::new("commands")) {
263        scan_dir(&user_dir, &mut commands, &mut seen);
264    }
265
266    commands
267}
268/// Try to match and expand a file-based slash command.
269/// `input` is the full prompt text starting with `/` (e.g. `"/review src/main.rs"`).
270/// Returns the expanded prompt text if a file command matched, or `None`.
271pub fn try_expand(commands: &[FileCommand], input: &str) -> Option<String> {
272    let trimmed = input.trim();
273    let after_slash = trimmed.strip_prefix('/')?;
274    let (token, args) = match after_slash.find(' ') {
275        Some(space) => (&after_slash[..space], after_slash[space + 1..].trim()),
276        None => (after_slash, ""),
277    };
278    for cmd in commands {
279        if cmd.matches(token) {
280            return Some(cmd.expand(args));
281        }
282    }
283    None
284}
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::fs;
289    use std::sync::Mutex;
290    use tempfile::TempDir;
291
292    static HOME_ENV_LOCK: Mutex<()> = Mutex::new(());
293
294    struct HomeGuard(String);
295
296    impl Drop for HomeGuard {
297        fn drop(&mut self) {
298            // SAFETY: tests that mutate HOME hold HOME_ENV_LOCK until after this guard drops.
299            unsafe { std::env::set_var("HOME", &self.0) };
300        }
301    }
302
303    #[test]
304    fn parse_with_frontmatter() {
305        let content = "---\ndescription: My review command\naliases: cr, review-code\n---\nReview this code:\n\n$ARGUMENTS\n";
306        let cmd = FileCommand::parse("review", content);
307        assert_eq!(cmd.name, "review");
308        assert_eq!(cmd.description, "My review command");
309        assert_eq!(cmd.aliases, vec!["cr", "review-code"]);
310        assert!(cmd.body.contains("Review this code:"));
311        assert!(cmd.body.contains("$ARGUMENTS"));
312    }
313
314    #[test]
315    fn parse_without_frontmatter_uses_body_and_first_line_description() {
316        let content = "Just do the thing\nWith more detail\n";
317        let cmd = FileCommand::parse("thing", content);
318        assert_eq!(cmd.name, "thing");
319        assert_eq!(cmd.description, "Just do the thing");
320        assert!(cmd.body.contains("Just do the thing"));
321    }
322
323    #[test]
324    fn parse_frontmatter_with_no_aliases() {
325        let content = "---\ndescription: A simple command\n---\nBody text\n";
326        let cmd = FileCommand::parse("simple", content);
327        assert!(cmd.aliases.is_empty());
328    }
329
330    #[test]
331    fn expand_arguments_placeholder() {
332        let body = "Review: $ARGUMENTS";
333        assert_eq!(expand_template(body, "src/main.rs"), "Review: src/main.rs");
334    }
335
336    #[test]
337    fn expand_at_placeholder() {
338        let body = "Check $@ now";
339        assert_eq!(expand_template(body, "foo bar"), "Check foo bar now");
340    }
341
342    #[test]
343    fn expand_positional() {
344        let body = "From $1 to $2";
345        assert_eq!(expand_template(body, "alpha beta"), "From alpha to beta");
346    }
347
348    #[test]
349    fn expand_missing_positional_becomes_empty() {
350        let body = "Only $1 and $2";
351        assert_eq!(expand_template(body, "alpha"), "Only alpha and ");
352    }
353
354    #[test]
355    fn expand_preserves_dollar_digit_in_args() {
356        assert_eq!(
357            expand_template("Summarize: $ARGUMENTS", "The $9 variable"),
358            "Summarize: The $9 variable"
359        );
360    }
361
362    #[test]
363    fn expand_no_placeholders_returns_body_unchanged() {
364        let body = "Static prompt text";
365        assert_eq!(expand_template(body, "ignored"), "Static prompt text");
366    }
367
368    #[test]
369    fn expand_empty_args() {
370        let body = "Do stuff with $ARGUMENTS";
371        assert_eq!(expand_template(body, ""), "Do stuff with ");
372    }
373
374    #[test]
375    fn matches_canonical_name() {
376        let cmd = FileCommand::parse("review", "---\ndescription: x\n---\nbody");
377        assert!(cmd.matches("review"));
378        assert!(!cmd.matches("other"));
379    }
380
381    #[test]
382    fn matches_alias() {
383        let cmd = FileCommand::parse("review", "---\ndescription: x\naliases: cr, rv\n---\nbody");
384        assert!(cmd.matches("cr"));
385        assert!(cmd.matches("rv"));
386    }
387
388    #[test]
389    fn expand_method_combines_template_and_args() {
390        let cmd = FileCommand::parse(
391            "test",
392            "---\ndescription: x\n---\nRun $1 tests for $ARGUMENTS",
393        );
394        let expanded = cmd.expand("unit src/");
395        assert_eq!(expanded, "Run unit tests for unit src/");
396    }
397
398    #[test]
399    fn split_args_basic() {
400        assert_eq!(split_args("foo bar baz"), vec!["foo", "bar", "baz"]);
401    }
402
403    #[test]
404    fn split_args_quoted() {
405        assert_eq!(
406            split_args("foo \"bar baz\" qux"),
407            vec!["foo", "bar baz", "qux"]
408        );
409    }
410
411    #[test]
412    fn load_from_project_dir() {
413        let _home_lock = HOME_ENV_LOCK
414            .lock()
415            .unwrap_or_else(std::sync::PoisonError::into_inner);
416        let tmp = TempDir::new().unwrap();
417        let tmp_home = TempDir::new().unwrap();
418        let old_home = std::env::var("HOME").unwrap_or_default();
419        let _home_guard = HomeGuard(old_home);
420        // SAFETY: HOME_ENV_LOCK serializes these process-wide test mutations.
421        unsafe { std::env::set_var("HOME", tmp_home.path()) };
422
423        let cmds_dir = tmp.path().join(".oxicode").join("commands");
424        fs::create_dir_all(&cmds_dir).unwrap();
425        fs::write(
426            cmds_dir.join("review.md"),
427            "---\ndescription: proj review\n---\nReview $ARGUMENTS",
428        )
429        .unwrap();
430
431        let cmds = load_file_commands(tmp.path());
432        assert_eq!(cmds.len(), 1);
433        assert_eq!(cmds[0].name, "review");
434        assert_eq!(cmds[0].description, "proj review");
435    }
436
437    #[test]
438    fn scan_dir_orders_commands_alphabetically() {
439        let tmp = TempDir::new().unwrap();
440        let cmds_dir = tmp.path().join(".oxicode").join("commands");
441        fs::create_dir_all(&cmds_dir).unwrap();
442        fs::write(cmds_dir.join("zulu.md"), "zulu body").unwrap();
443        fs::write(cmds_dir.join("alpha.md"), "alpha body").unwrap();
444
445        let mut cmds = Vec::new();
446        let mut seen = HashSet::new();
447        scan_dir(&cmds_dir, &mut cmds, &mut seen);
448
449        let names: Vec<&str> = cmds.iter().map(|cmd| cmd.name.as_str()).collect();
450        assert_eq!(names, vec!["alpha", "zulu"]);
451    }
452
453    #[test]
454    fn project_shadows_user_on_name_collision() {
455        let tmp_proj = TempDir::new().unwrap();
456        let tmp_user = TempDir::new().unwrap();
457
458        // Create a fake user home
459        let user_oxicode = tmp_user.path().join(".oxicode").join("commands");
460        fs::create_dir_all(&user_oxicode).unwrap();
461        fs::write(
462            user_oxicode.join("shared.md"),
463            "---\ndescription: USER version\n---\nuser body",
464        )
465        .unwrap();
466
467        // Project version
468        let proj_cmds = tmp_proj.path().join(".oxicode").join("commands");
469        fs::create_dir_all(&proj_cmds).unwrap();
470        fs::write(
471            proj_cmds.join("shared.md"),
472            "---\ndescription: PROJECT version\n---\nproj body",
473        )
474        .unwrap();
475
476        // We can't easily mock dirs::home_dir, so test the internal
477        // scan logic directly.
478        let mut cmds = Vec::new();
479        let mut seen = HashSet::new();
480        scan_dir(&proj_cmds, &mut cmds, &mut seen);
481        scan_dir(&user_oxicode, &mut cmds, &mut seen);
482
483        assert_eq!(cmds.len(), 1);
484        assert_eq!(cmds[0].description, "PROJECT version");
485    }
486
487    #[test]
488    fn load_ignores_non_md_files() {
489        let tmp = TempDir::new().unwrap();
490        let cmds_dir = tmp.path().join(".oxicode").join("commands");
491        fs::create_dir_all(&cmds_dir).unwrap();
492        fs::write(cmds_dir.join("valid.md"), "---\ndescription: x\n---\nbody").unwrap();
493        fs::write(cmds_dir.join("readme.txt"), "not a command").unwrap();
494        fs::write(
495            cmds_dir.join(".hidden.md"),
496            "---\ndescription: hidden\n---\nbody",
497        )
498        .unwrap();
499
500        let mut cmds = Vec::new();
501        let mut seen = HashSet::new();
502        scan_dir(&cmds_dir, &mut cmds, &mut seen);
503
504        assert_eq!(cmds.len(), 1);
505        assert_eq!(cmds[0].name, "valid");
506    }
507
508    #[test]
509    fn load_handles_missing_dir_gracefully() {
510        let _home_lock = HOME_ENV_LOCK
511            .lock()
512            .unwrap_or_else(std::sync::PoisonError::into_inner);
513        let tmp = TempDir::new().unwrap();
514        let tmp_home = TempDir::new().unwrap();
515        let old_home = std::env::var("HOME").unwrap_or_default();
516        let _home_guard = HomeGuard(old_home);
517        // SAFETY: HOME_ENV_LOCK serializes these process-wide test mutations.
518        unsafe { std::env::set_var("HOME", tmp_home.path()) };
519
520        // No .oxicode/commands/ exists — should return empty, not error.
521        let cmds = load_file_commands(tmp.path());
522        assert!(cmds.is_empty());
523    }
524
525    #[test]
526    fn split_args_empty() {
527        assert!(split_args("").is_empty());
528    }
529
530    #[test]
531    fn try_expand_matches_canonical_name() {
532        let cmds = vec![FileCommand::parse(
533            "review",
534            "---\ndescription: x\n---\nReview $ARGUMENTS",
535        )];
536        let result = try_expand(&cmds, "/review src/main.rs");
537        assert_eq!(result.as_deref(), Some("Review src/main.rs"));
538    }
539
540    #[test]
541    fn try_expand_matches_alias() {
542        let cmds = vec![FileCommand::parse(
543            "review",
544            "---\ndescription: x\naliases: cr\n---\nCode review: $ARGUMENTS",
545        )];
546        let result = try_expand(&cmds, "/cr src/lib.rs");
547        assert_eq!(result.as_deref(), Some("Code review: src/lib.rs"));
548    }
549
550    #[test]
551    fn try_expand_no_args() {
552        let cmds = vec![FileCommand::parse(
553            "deploy",
554            "---\ndescription: x\n---\nDeploy everything now",
555        )];
556        let result = try_expand(&cmds, "/deploy");
557        assert_eq!(result.as_deref(), Some("Deploy everything now"));
558    }
559
560    #[test]
561    fn try_expand_no_match_returns_none() {
562        let cmds = vec![FileCommand::parse(
563            "review",
564            "---\ndescription: x\n---\nbody",
565        )];
566        assert!(try_expand(&cmds, "/nonexistent").is_none());
567    }
568
569    #[test]
570    fn try_expand_empty_commands_returns_none() {
571        assert!(try_expand(&[], "/anything").is_none());
572    }
573}