Skip to main content

tryme_core/
dispatch.rs

1//! Command dispatch — port of the `__FILE__ == $0` driver
2//! (`try.rb:1008-1587`): flag extraction, the command case tree, and the
3//! exit-code contract (0 = script emitted, 1 = cancel/error, 2 = bare help).
4
5use crate::argv::{normalize, Normalized};
6use crate::emit::ScriptOut;
7use crate::env::Env;
8use crate::giturl::{generate_clone_directory_name, is_git_uri};
9use crate::help::global_help;
10use crate::naming::{resolve_unique_name_with_versioning, squeeze_ws_to_hyphen, worktree_path};
11use crate::scripts;
12use crate::testkeys::parse_test_keys;
13use crate::wrappers::{
14    detect_shell, expand_path, init_snippet, is_fish, resolve_self_path, shell_rc_file, Shell,
15};
16use std::io::Write;
17use std::path::{Path, PathBuf};
18
19/// Everything an invocation needs besides argv: injected so tests and the
20/// conformance suite drive the same code as production.
21pub struct Ctx {
22    /// Environment snapshot.
23    pub env: Env,
24    /// Process working directory.
25    pub cwd: PathBuf,
26    /// Raw `argv[0]` — `expand_path`'d (never canonicalized) for wrapper
27    /// emission.
28    pub arg0: String,
29    /// This package's version (rendered into help/version output).
30    pub version: String,
31    /// Local date as `YYYY-MM-DD` (upstream calls `Time.now.strftime`).
32    pub today: String,
33}
34
35/// Run a full invocation. Writes TUI/help/errors to `err` (stderr) and the
36/// emitted script to `out` (stdout); returns the process exit code.
37pub fn run<W: Write>(
38    args: Vec<String>,
39    ctx: &Ctx,
40    err: &mut dyn Write,
41    out: &mut ScriptOut<W>,
42) -> u8 {
43    let n = normalize(args);
44
45    // Color gate: NO_COLORS at "module load" (tui.rb:25), then the CLI
46    // aliases, then NO_COLOR (try.rb:1009-1013)
47    crate::tui::set_colors_enabled(ctx.env.no_colors.as_deref().unwrap_or("").is_empty());
48    if n.colors_disabled || ctx.env.no_color.as_deref().is_some_and(|v| !v.is_empty()) {
49        crate::tui::set_colors_enabled(false);
50    }
51
52    // --help / -h anywhere → help on stderr, exit 0 (try.rb:1016-1019)
53    if n.help {
54        let _ = write!(err, "{}", global_help(&ctx.version));
55        return 0;
56    }
57    // --version / -v anywhere → stderr, exit 0 (try.rb:1022-1025)
58    if n.version {
59        let _ = writeln!(err, "try {}", ctx.version);
60        return 0;
61    }
62
63    // --path > TRY_PATH env > ~/src/tries, then expand (try.rb:12,1081-1082)
64    let tries_raw = n
65        .path
66        .clone()
67        .or_else(|| ctx.env.try_path.clone())
68        .unwrap_or_else(|| "~/src/tries".to_string());
69    let tries_path = expand_path(&tries_raw, &ctx.cwd, ctx.env.home.as_deref());
70
71    let mut rest = n.rest.clone();
72    if rest.is_empty() {
73        // Bare `try`: help + exit 2 (try.rb:1526-1528)
74        let _ = write!(err, "{}", global_help(&ctx.version));
75        return 2;
76    }
77    let command = rest.remove(0);
78
79    match command.as_str() {
80        "clone" => match cmd_clone(&rest, &tries_path, &ctx.today, err) {
81            Ok(cmds) => {
82                let _ = out.emit_script(&cmds);
83                0
84            }
85            Err(code) => code,
86        },
87        "init" => {
88            let snippet = build_init_snippet(&rest, ctx, &tries_path);
89            let _ = out.raw(&snippet);
90            0
91        }
92        "install" => cmd_install(&rest, ctx, &tries_path, err),
93        "exec" => {
94            let sub = rest.first().cloned();
95            match sub.as_deref() {
96                Some("clone") => match cmd_clone(&rest[1..], &tries_path, &ctx.today, err) {
97                    Ok(cmds) => {
98                        let _ = out.emit_script(&cmds);
99                        0
100                    }
101                    Err(code) => code,
102                },
103                Some("worktree") => {
104                    let cmds = cmd_worktree(&rest[1..], ctx, &tries_path);
105                    let _ = out.emit_script(&cmds);
106                    0
107                }
108                other => {
109                    // `exec cd` shifts the sub; anything else keeps args
110                    // intact (try.rb:1550-1568)
111                    let args = if other == Some("cd") {
112                        &rest[1..]
113                    } else {
114                        &rest[..]
115                    };
116                    finish_cd(args, ctx, &tries_path, &n, err, out)
117                }
118            }
119        }
120        "worktree" => {
121            let cmds = cmd_worktree(&rest, ctx, &tries_path);
122            let _ = out.emit_script(&cmds);
123            0
124        }
125        _ => {
126            // Default: try [query] — command becomes part of the query
127            // (try.rb:1577-1586)
128            let mut args = vec![command];
129            args.extend(rest);
130            finish_cd(&args, ctx, &tries_path, &n, err, out)
131        }
132    }
133}
134
135/// Shared tail of the `exec`/default branches: emit script + 0, or
136/// `Cancelled.` on STDOUT + 1 (try.rb:1552-1568, 1579-1586).
137fn finish_cd<W: Write>(
138    args: &[String],
139    ctx: &Ctx,
140    tries_path: &Path,
141    n: &Normalized,
142    err: &mut dyn Write,
143    out: &mut ScriptOut<W>,
144) -> u8 {
145    match cmd_cd(args, ctx, tries_path, n, err) {
146        Ok(Some(cmds)) => {
147            let _ = out.emit_script(&cmds);
148            0
149        }
150        Ok(None) => {
151            let _ = out.cancelled();
152            1
153        }
154        Err(code) => code,
155    }
156}
157
158/// Port of `cmd_clone!` (`try.rb:1153-1170`).
159fn cmd_clone(
160    args: &[String],
161    tries_path: &Path,
162    today: &str,
163    err: &mut dyn Write,
164) -> Result<Vec<String>, u8> {
165    let Some(git_uri) = args.first() else {
166        let _ = writeln!(err, "Error: git URI required for clone command");
167        let _ = writeln!(err, "Usage: try clone <git-uri> [name]");
168        return Err(1);
169    };
170    let custom_name = args.get(1).map(String::as_str);
171    let Some(dir_name) = generate_clone_directory_name(git_uri, custom_name, today) else {
172        let _ = writeln!(err, "Error: Unable to parse git URI: {git_uri}");
173        return Err(1);
174    };
175    Ok(scripts::script_clone(&tries_path.join(dir_name), git_uri))
176}
177
178/// Port of the `worktree` command body (`try.rb:1545-1549, 1570-1576`),
179/// including the literal `dir` token quirk: `try worktree dir <name>`
180/// treats `dir` as "use the cwd", not a repo path.
181fn cmd_worktree(args: &[String], ctx: &Ctx, tries_path: &Path) -> Vec<String> {
182    let repo = args.first();
183    let repo_dir = match repo {
184        Some(r) if r != "dir" => expand_path(r, &ctx.cwd, ctx.env.home.as_deref()),
185        _ => ctx.cwd.clone(),
186    };
187    let custom = args.get(1..).unwrap_or(&[]).join(" ");
188    let full_path = worktree_path(tries_path, &repo_dir, &custom, &ctx.today);
189    let repo_arg = if repo_dir == ctx.cwd {
190        None
191    } else {
192        Some(repo_dir.as_path())
193    };
194    scripts::script_worktree(&full_path, repo_arg, &ctx.cwd)
195}
196
197/// Port of `cmd_cd!` (`try.rb:1315-1386`): clone passthrough, the
198/// dot-shorthand, the git-URL shorthand, then the interactive selector.
199fn cmd_cd(
200    args: &[String],
201    ctx: &Ctx,
202    tries_path: &Path,
203    n: &Normalized,
204    err: &mut dyn Write,
205) -> Result<Option<Vec<String>>, u8> {
206    use crate::selector::Selection;
207
208    if args.first().map(String::as_str) == Some("clone") {
209        return cmd_clone(&args[1..], tries_path, &ctx.today, err).map(Some);
210    }
211
212    // try . [name] / try ./path [name] (try.rb:1321-1345)
213    if let Some(path_arg) = args.first().filter(|a| a.starts_with('.')) {
214        let custom = args[1..].join(" ");
215        let repo_dir = expand_path(path_arg, &ctx.cwd, ctx.env.home.as_deref());
216        if path_arg == "." && custom.trim().is_empty() {
217            let _ = writeln!(err, "Error: 'try .' requires a name argument");
218            let _ = writeln!(err, "Usage: try . <name>");
219            return Err(1);
220        }
221        let base = if custom.trim().is_empty() {
222            repo_dir
223                .file_name()
224                .map_or_else(String::new, |f| f.to_string_lossy().into_owned())
225        } else {
226            squeeze_ws_to_hyphen(&custom)
227        };
228        let base = resolve_unique_name_with_versioning(tries_path, &ctx.today, &base);
229        let full_path = tries_path.join(format!("{}-{base}", ctx.today));
230        // Worktree when .git exists — file (worktrees) OR directory (repos)
231        return Ok(Some(if repo_dir.join(".git").exists() {
232            scripts::script_worktree(&full_path, Some(&repo_dir), &ctx.cwd)
233        } else {
234            scripts::script_mkdir_cd(&full_path)
235        }));
236    }
237
238    let search_term = args.join(" ");
239
240    // Git URL shorthand → clone workflow (try.rb:1350-1359)
241    if is_git_uri(search_term.split_whitespace().next().unwrap_or("")) {
242        let mut parts = search_term.splitn(2, char::is_whitespace);
243        let git_uri = parts.next().unwrap_or("").to_string();
244        let custom_name = parts.next().map(str::trim_start).filter(|s| !s.is_empty());
245        let Some(dir_name) = generate_clone_directory_name(&git_uri, custom_name, &ctx.today)
246        else {
247            let _ = writeln!(err, "Error: Unable to parse git URI: {git_uri}");
248            return Err(1);
249        };
250        return Ok(Some(scripts::script_clone(
251            &tries_path.join(dir_name),
252            &git_uri,
253        )));
254    }
255
256    // Regular interactive selector (try.rb:1361-1385)
257    let test_keys = n.and_keys_raw.as_deref().and_then(parse_test_keys);
258    let selector = crate::selector::Selector::new(
259        &search_term,
260        tries_path.to_path_buf(),
261        &ctx.env,
262        n.and_type.as_deref(),
263        n.and_exit,
264        test_keys,
265        n.and_confirm.clone(),
266    );
267    let Some(selection) = selector.run() else {
268        return Ok(None);
269    };
270    Ok(Some(match selection {
271        Selection::Cd { path } => scripts::script_cd(&path),
272        Selection::Mkdir { path } => scripts::script_mkdir_cd(&path),
273        Selection::Rename {
274            base_path,
275            old,
276            new,
277        } => scripts::script_rename(&base_path, &old, &new),
278        Selection::Ascend {
279            source,
280            dest,
281            basename,
282            base_path,
283        } => scripts::script_ascend(&source, &dest, &basename, &base_path),
284        Selection::Delete {
285            basenames,
286            base_path,
287        } => scripts::script_delete(&basenames, &base_path, &ctx.cwd),
288    }))
289}
290
291/// Port of `cmd_init!` (`try.rb:1172-1183`): positional path only when it
292/// starts with `/`; fish vs bash selection via `fish?`.
293fn build_init_snippet(args: &[String], ctx: &Ctx, tries_path: &Path) -> String {
294    let script_path = resolve_self_path(&ctx.arg0, &ctx.cwd, &ctx.env);
295    let explicit_path = args
296        .first()
297        .filter(|a| a.starts_with('/'))
298        .map(|a| expand_path(a, &ctx.cwd, ctx.env.home.as_deref()));
299    let shell = if is_fish(&ctx.env) {
300        Shell::Fish
301    } else {
302        Shell::Bash
303    };
304    init_snippet(shell, &script_path, explicit_path.as_deref(), tries_path)
305}
306
307/// Port of `cmd_install!` (`try.rb:1185-1228`): detect shell, locate the rc
308/// file, append the wrapper idempotently. All messages to stderr.
309fn cmd_install(args: &[String], ctx: &Ctx, tries_path: &Path, err: &mut dyn Write) -> u8 {
310    let script_path = resolve_self_path(&ctx.arg0, &ctx.cwd, &ctx.env);
311    let explicit_path = args
312        .first()
313        .filter(|a| a.starts_with('/'))
314        .map(|a| expand_path(a, &ctx.cwd, ctx.env.home.as_deref()));
315
316    let shell = detect_shell(&ctx.env);
317    let rc_file = shell.and_then(|s| shell_rc_file(s, &ctx.env));
318
319    let Some(rc_file) = rc_file else {
320        let _ = writeln!(err, "Error: could not determine shell config file");
321        let _ = writeln!(
322            err,
323            "Your shell was detected as: {}",
324            shell.map_or("unknown".to_string(), |s| format!("{s:?}").to_lowercase())
325        );
326        let _ = writeln!(
327            err,
328            "Run 'try init' and manually add the output to your shell config."
329        );
330        return 1;
331    };
332    let shell = shell.expect("rc_file implies shell");
333    let snippet = init_snippet(shell, &script_path, explicit_path.as_deref(), tries_path);
334    let rc_path = expand_path(&rc_file, &ctx.cwd, ctx.env.home.as_deref());
335
336    if rc_path.exists() {
337        let contents = std::fs::read_to_string(&rc_path).unwrap_or_default();
338        if contents.contains("# try shell integration") {
339            let _ = writeln!(err, "try is already installed in {}", rc_path.display());
340            let _ = writeln!(
341                err,
342                "To reinstall, remove the '# try shell integration' block first."
343            );
344            return 0;
345        }
346        let readonly = std::fs::metadata(&rc_path).is_ok_and(|m| m.permissions().readonly());
347        if readonly {
348            let _ = writeln!(
349                err,
350                "Warning: {} is read-only, skipping.",
351                rc_path.display()
352            );
353            let _ = writeln!(
354                err,
355                "Run 'try init' and manually add the output to your shell config."
356            );
357            return 1;
358        }
359    }
360
361    if let Some(parent) = rc_path.parent() {
362        let _ = std::fs::create_dir_all(parent);
363    }
364    let block = format!("\n# try shell integration\n{snippet}");
365    if let Ok(mut f) = std::fs::OpenOptions::new()
366        .create(true)
367        .append(true)
368        .open(&rc_path)
369    {
370        let _ = f.write_all(block.as_bytes());
371    }
372    let _ = writeln!(err, "Added try shell integration to {}", rc_path.display());
373    if shell == Shell::Pwsh {
374        let _ = writeln!(err, "Restart your shell or run: . $PROFILE");
375    } else {
376        let _ = writeln!(
377            err,
378            "Restart your shell or run: source {}",
379            rc_path.display()
380        );
381    }
382    0
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn ctx(cwd: &Path) -> Ctx {
390        Ctx {
391            env: Env {
392                home: Some("/home/u".into()),
393                shell: Some("/bin/bash".into()),
394                ..Env::default()
395            },
396            cwd: cwd.to_path_buf(),
397            arg0: "/bin/tryme".into(),
398            version: "0.0.0".into(),
399            today: "2026-07-10".into(),
400        }
401    }
402
403    fn run_capture(args: &[&str], c: &Ctx) -> (u8, String, String) {
404        let mut errb = Vec::new();
405        let mut outb = Vec::new();
406        let code = {
407            let mut out = ScriptOut::new(&mut outb);
408            run(
409                args.iter().map(ToString::to_string).collect(),
410                c,
411                &mut errb,
412                &mut out,
413            )
414        };
415        (
416            code,
417            String::from_utf8(outb).unwrap(),
418            String::from_utf8(errb).unwrap(),
419        )
420    }
421
422    #[test]
423    fn bare_invocation_help_exit_2_stdout_empty() {
424        let tmp = tempfile::tempdir().unwrap();
425        let (code, out, err) = run_capture(&[], &ctx(tmp.path()));
426        assert_eq!(code, 2);
427        assert!(out.is_empty());
428        assert!(err.starts_with("try v0.0.0 - ephemeral workspace manager"));
429    }
430
431    #[test]
432    fn version_and_help_exit_0_on_stderr() {
433        let tmp = tempfile::tempdir().unwrap();
434        let (code, out, err) = run_capture(&["--version"], &ctx(tmp.path()));
435        assert_eq!((code, out.as_str(), err.as_str()), (0, "", "try 0.0.0\n"));
436        let (code, out, err) = run_capture(&["clone", "-h"], &ctx(tmp.path()));
437        assert_eq!(code, 0);
438        assert!(out.is_empty());
439        assert!(err.contains("ephemeral workspace manager"));
440    }
441
442    #[test]
443    fn clone_emits_script_with_dated_name() {
444        let tmp = tempfile::tempdir().unwrap();
445        let c = ctx(tmp.path());
446        let (code, out, _) = run_capture(&["--path", "/t", "clone", "https://github.com/u/r"], &c);
447        assert_eq!(code, 0);
448        assert!(out.contains("git clone 'https://github.com/u/r' '/t/2026-07-10-u-r'"));
449        assert!(out.starts_with("# if you can read this"));
450    }
451
452    #[test]
453    fn clone_missing_and_bad_uri_exit_1() {
454        let tmp = tempfile::tempdir().unwrap();
455        let c = ctx(tmp.path());
456        let (code, out, err) = run_capture(&["clone"], &c);
457        assert_eq!(code, 1);
458        assert!(out.is_empty());
459        assert!(err.contains("git URI required"));
460        let (code, _, err) = run_capture(&["clone", "not a uri"], &c);
461        assert_eq!(code, 1);
462        assert!(err.contains("Unable to parse git URI"));
463    }
464
465    #[test]
466    fn url_shorthand_routes_to_clone() {
467        let tmp = tempfile::tempdir().unwrap();
468        let (code, out, _) = run_capture(
469            &["--path", "/t", "https://github.com/u/r"],
470            &ctx(tmp.path()),
471        );
472        assert_eq!(code, 0);
473        assert!(out.contains("git clone 'https://github.com/u/r'"));
474    }
475
476    #[test]
477    fn selector_path_cancels_with_stdout_cancelled() {
478        let tmp = tempfile::tempdir().unwrap();
479        let (code, out, _) = run_capture(&["--and-exit", "exec"], &ctx(tmp.path()));
480        assert_eq!(code, 1);
481        assert_eq!(out, "Cancelled.\n");
482    }
483
484    #[test]
485    fn bare_dot_requires_name() {
486        let tmp = tempfile::tempdir().unwrap();
487        let (code, _, err) = run_capture(&["exec", "cd", "."], &ctx(tmp.path()));
488        assert_eq!(code, 1);
489        assert!(err.contains("'try .' requires a name argument"));
490    }
491
492    #[test]
493    fn worktree_dir_token_means_cwd() {
494        let tmp = tempfile::tempdir().unwrap();
495        let c = ctx(tmp.path());
496        let (code, out, _) = run_capture(&["--path", "/t", "worktree", "dir", "feat"], &c);
497        assert_eq!(code, 0);
498        // cwd variant: no -C in the guard
499        assert!(out.contains("if git rev-parse --is-inside-work-tree"));
500        assert!(out.contains("'/t/2026-07-10-feat'"));
501    }
502
503    #[test]
504    fn init_emits_wrapper_on_stdout() {
505        let tmp = tempfile::tempdir().unwrap();
506        let (code, out, _) = run_capture(&["--path", "/t", "init"], &ctx(tmp.path()));
507        assert_eq!(code, 0);
508        assert!(out.starts_with("try() {\n"));
509        assert!(out.contains("'/bin/tryme' exec --path \"${TRY_PATH:-/t}\""));
510    }
511}