1use 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
19pub struct Ctx {
22 pub env: Env,
24 pub cwd: PathBuf,
26 pub arg0: String,
29 pub version: String,
31 pub today: String,
33}
34
35pub 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 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 if n.help {
54 let _ = write!(err, "{}", global_help(&ctx.version));
55 return 0;
56 }
57 if n.version {
59 let _ = writeln!(err, "try {}", ctx.version);
60 return 0;
61 }
62
63 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 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 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 let mut args = vec![command];
129 args.extend(rest);
130 finish_cd(&args, ctx, &tries_path, &n, err, out)
131 }
132 }
133}
134
135fn 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
158fn 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
178fn 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
197fn 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 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 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 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 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
291fn 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
307fn 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 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}