mod cli;
mod error;
mod model;
mod score;
mod selector;
mod storage;
mod tui;
mod util;
use crate::error::Result;
use clap::error::ErrorKind;
use clap::{Parser, Subcommand};
use std::{env, ffi::OsString, io, io::IsTerminal, path::Path, path::PathBuf};
const SHELL_WRAPPED_ENV: &str = "TRY_SHELL_WRAPPED";
const SHELL_INTEGRATION_HINT: &str = r#"Shell integration is not active. Run: eval "$(try init)""#;
const INVALID_SUBCOMMAND_HINT: &str =
r#"Shell integration is not active. Use `try cd <query>` directly, or run: eval "$(try init)""#;
const TOP_LEVEL_AFTER_HELP: &str = concat!(
"After `cargo install try-cli`, enable shell integration:\n",
" bash/zsh: eval \"$(try init)\"\n",
" fish: eval \"$(try init | string collect)\"\n",
"\n",
"Direct binary usage without shell integration:\n",
" try cd my-experiment\n",
" try clone https://github.com/user/repo.git\n",
);
const INIT_AFTER_HELP: &str = concat!(
"This command prints shell code.\n",
"Evaluate it from your shell config instead of running it directly:\n",
" bash/zsh: eval \"$(try init)\"\n",
" fish: eval \"$(try init | string collect)\"\n",
);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ShellKind {
Fish,
Posix,
}
#[derive(Parser, Debug)]
#[command(
name = "try",
version,
about = "Interactive try-dir selector",
disable_help_subcommand = true,
after_help = TOP_LEVEL_AFTER_HELP
)]
struct Cli {
#[arg(long, global = true, value_name = "PATH")]
path: Option<PathBuf>,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
#[command(after_help = INIT_AFTER_HELP)]
Init {
#[arg(long, value_name = "PATH")]
path: Option<PathBuf>,
#[arg(value_name = "PATH")]
abs_path: Option<PathBuf>,
},
Cd {
#[arg(value_name = "QUERY", trailing_var_arg = true)]
query: Vec<String>,
},
Clone {
git_uri: String,
name: Option<String>,
},
}
fn shell_kind() -> ShellKind {
if util::is_fish_shell() {
ShellKind::Fish
} else {
ShellKind::Posix
}
}
fn render_init_script(script_path: &Path, tries_path: &Path, shell_kind: ShellKind) -> String {
let path_arg = format!(r#" --path "{}""#, tries_path.display());
match shell_kind {
ShellKind::Fish => format!(
r#"function try
set -l script_path "{}"
switch "$argv[1]"
case -h --help -V --version init
/usr/bin/env TRY_SHELL_WRAPPED=1 "$script_path" $argv 2>/dev/tty
return
case cd clone
switch "$argv[2]"
case -h --help -V --version
/usr/bin/env TRY_SHELL_WRAPPED=1 "$script_path" $argv 2>/dev/tty
return
end
end
switch "$argv[1]"
case cd
set argv cd{} $argv[2..-1]
case clone
set argv clone{} $argv[2..-1]
case '*'
set argv cd{} $argv
end
set -l cmd (/usr/bin/env TRY_SHELL_WRAPPED=1 "$script_path" $argv 2>/dev/tty | string collect)
set -l cmd_status $status
test $cmd_status -eq 0 && eval $cmd || echo $cmd
end"#,
script_path.display(),
path_arg,
path_arg,
path_arg
),
ShellKind::Posix => format!(
r#"try() {{
script_path='{}'
case "$1" in
-h|--help|-V|--version|init)
/usr/bin/env TRY_SHELL_WRAPPED=1 "$script_path" "$@" 2>/dev/tty
return;;
cd|clone)
case "$2" in
-h|--help|-V|--version)
/usr/bin/env TRY_SHELL_WRAPPED=1 "$script_path" "$@" 2>/dev/tty
return;;
esac;;
esac
case "$1" in
cd)
shift
set -- cd{} "$@";;
clone)
shift
set -- clone{} "$@";;
*)
set -- cd{} "$@";;
esac
tmp=$(mktemp 2>/dev/null || echo "/tmp/try-cmd-$$")
/usr/bin/env TRY_SHELL_WRAPPED=1 "$script_path" "$@" > "$tmp" 2>/dev/tty
cmd_status=$?
cmd=$(cat "$tmp" 2>/dev/null)
rm -f "$tmp" 2>/dev/null
if [ $cmd_status -eq 0 ] && [ -n "$cmd" ]; then
eval "$cmd"
else
[ -n "$cmd" ] && echo "$cmd"
fi
}}"#,
script_path.display(),
path_arg,
path_arg,
path_arg
),
}
}
fn shell_integration_hint_needed(
is_wrapped: bool,
stdout_is_terminal: bool,
stderr_is_terminal: bool,
) -> bool {
!is_wrapped && stdout_is_terminal && stderr_is_terminal
}
fn should_show_shell_integration_hint() -> bool {
shell_integration_hint_needed(
env::var_os(SHELL_WRAPPED_ENV).is_some(),
io::stdout().is_terminal(),
io::stderr().is_terminal(),
)
}
fn print_shell_integration_hint(message: &str) {
let mut err = io::stderr();
let _ = crate::tui::warn(&mut err, message);
}
fn maybe_print_shell_integration_hint() {
if should_show_shell_integration_hint() {
print_shell_integration_hint(SHELL_INTEGRATION_HINT);
}
}
fn main() -> Result<()> {
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
match e.kind() {
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
eprintln!("{e}");
std::process::exit(0);
}
ErrorKind::InvalidSubcommand => {
let _ = e.print();
if should_show_shell_integration_hint() {
print_shell_integration_hint(INVALID_SUBCOMMAND_HINT);
}
std::process::exit(e.exit_code());
}
_ => {
e.exit();
}
}
}
};
let base_path = cli
.path
.clone()
.unwrap_or_else(selector::TrySelector::default_base_path);
match cli.command {
None => {
maybe_print_shell_integration_hint();
cli::run_cd_flow(String::new(), &base_path)
}
Some(Commands::Init { path, abs_path }) => {
let script_path = env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok())
.unwrap_or_else(|| PathBuf::from("try"));
let mut tries_path = path
.or(abs_path.filter(|p| p.is_absolute()))
.unwrap_or(base_path.clone());
if let Some(s) = tries_path.to_str()
&& s.starts_with("~/")
{
tries_path = util::shellexpand_home(s);
}
println!(
"{}",
render_init_script(&script_path, &tries_path, shell_kind())
);
Ok(())
}
Some(Commands::Cd { query }) => {
maybe_print_shell_integration_hint();
let query_os: Vec<OsString> = query.into_iter().map(OsString::from).collect();
let query_str = cli::build_cd_query(&query_os);
cli::run_cd_flow(query_str, &base_path)
}
Some(Commands::Clone { git_uri, name }) => {
maybe_print_shell_integration_hint();
let dir_name = util::generate_clone_directory_name(&git_uri, name.as_deref());
if dir_name.is_none() {
let mut err = io::stderr();
let _ = crate::tui::error(&mut err, &format!("Unable to parse git URI: {git_uri}"));
std::process::exit(1);
}
let full = base_path.join(dir_name.unwrap());
let mut parts: Vec<String> = Vec::new();
parts.push(util::dir_assign_for_shell(&full));
parts.push("mkdir -p \"$dir\"".into());
parts.push(format!("git clone '{git_uri}' \"$dir\""));
parts.push("touch \"$dir\"".into());
parts.push("cd \"$dir\"".into());
println!("{}", util::join_shell(&parts));
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use clap::CommandFactory;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
#[test]
fn test_sanitize_query_filters_disallowed() {
let input = "Hello,_World-!@$ 42.";
let out = crate::util::sanitize_query(input);
assert_eq!(out, "Hello_World- 42.");
}
#[test]
fn test_is_printable() {
assert!(crate::util::is_printable('a'));
assert!(crate::util::is_printable('-'));
assert!(crate::util::is_printable('_'));
assert!(crate::util::is_printable('.'));
assert!(crate::util::is_printable(' '));
assert!(!crate::util::is_printable('\n'));
assert!(!crate::util::is_printable('!'));
}
#[test]
fn test_split_date_prefixed() {
let s = "2025-08-26-hello";
let p = crate::util::split_date_prefixed(s);
assert_eq!(p, Some(("2025-08-26", "hello")));
assert_eq!(crate::util::split_date_prefixed("foo"), None);
assert_eq!(
crate::util::split_date_prefixed("2025-08-2x-hello"),
Some(("2025-08-2x", "hello"))
);
}
#[test]
fn test_display_width_ascii() {
assert_eq!(crate::tui::display_width("hello"), 5);
assert_eq!(crate::tui::display_width(""), 0);
}
#[test]
fn test_civil_from_days_epoch() {
let (y, m, d) = crate::util::civil_from_days(0);
assert_eq!((y, m, d), (1970, 1, 1));
}
#[test]
fn test_calculate_score_basic() {
let s1 = crate::score::calculate_score("2025-08-26-test", "", None, None);
let s2 = crate::score::calculate_score("foo", "", None, None);
assert!(s1 > s2);
assert_eq!(s2, 0.0);
assert_eq!(crate::score::calculate_score("abc", "zz", None, None), 0.0);
assert!(crate::score::calculate_score("foo-test", "ft", None, None) > 0.0);
}
#[test]
fn test_format_relative_time_none() {
assert_eq!(crate::tui::format_relative_time(None), "?");
}
#[test]
fn test_extract_option_with_value_removes_and_last_wins() {
let mut args = vec![
OsString::from("--path"),
OsString::from("/tmp/x"),
OsString::from("cd"),
];
let val = crate::util::extract_option_with_value(&mut args, "--path");
assert_eq!(val.as_deref(), Some("/tmp/x"));
assert_eq!(args, vec![OsString::from("cd")]);
let mut args = vec![
OsString::from("--path=/a"),
OsString::from("--path"),
OsString::from("/b"),
OsString::from("init"),
];
let val = crate::util::extract_option_with_value(&mut args, "--path");
assert_eq!(val.as_deref(), Some("/b"));
assert_eq!(args, vec![OsString::from("init")]);
}
#[test]
fn test_shell_escape_single_quotes() {
let path = PathBuf::from("/tmp/it's ok");
let escaped = crate::util::shell_escape(path);
assert_eq!(escaped, "'/tmp/it'\\''s ok'");
}
#[test]
fn test_normalize_query_for_match_spaces_to_dash_and_sanitize() {
let q = "Hello, World!!";
let norm = crate::storage::normalize_query_for_match(q);
assert_eq!(norm, "Hello-World");
}
#[test]
fn test_fast_create_skips_when_exact_exists_ignoring_date() -> io::Result<()> {
let tmp_root = std::env::temp_dir().join(format!("tryrs-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp_root);
fs::create_dir_all(&tmp_root)?;
let existing = tmp_root.join("2025-08-26-foo-bar");
fs::create_dir_all(&existing)?;
let res = crate::storage::fast_create_target_if_no_exact(&tmp_root, "foo bar")?;
assert!(res.is_none());
let _ = fs::remove_dir_all(&tmp_root);
Ok(())
}
#[test]
fn test_fast_create_returns_target_when_no_match() -> io::Result<()> {
let tmp_root = std::env::temp_dir().join(format!("tryrs-test2-{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp_root);
fs::create_dir_all(&tmp_root)?;
let q = "new thing";
let res = crate::storage::fast_create_target_if_no_exact(&tmp_root, q)?;
assert!(res.is_some());
let p = res.unwrap();
let bn = p.file_name().unwrap().to_string_lossy().to_string();
assert!(bn.starts_with(&crate::util::today_prefix()));
assert!(bn.ends_with("-new-thing"));
let _ = fs::remove_dir_all(&tmp_root);
Ok(())
}
#[test]
fn test_fast_create_sanitizes_query() -> io::Result<()> {
let tmp_root = std::env::temp_dir().join(format!("tryrs-test3-{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp_root);
fs::create_dir_all(&tmp_root)?;
let q = "Hello,_World-!@$ 42."; let res = crate::storage::fast_create_target_if_no_exact(&tmp_root, q)?;
let p = res.unwrap();
let bn = p.file_name().unwrap().to_string_lossy().to_string();
assert!(bn.contains("Hello_World-"));
assert!(bn.ends_with("-42."));
let _ = fs::remove_dir_all(&tmp_root);
Ok(())
}
#[test]
fn test_compute_viewport_no_scroll_needed() {
let (s, e) = crate::tui::compute_viewport(0, 0, 3, 5);
assert_eq!((s, e), (0, 3));
let (s, e) = crate::tui::compute_viewport(1, 0, 3, 5);
assert_eq!((s, e), (0, 3));
let (s, e) = crate::tui::compute_viewport(2, 0, 3, 5);
assert_eq!((s, e), (0, 3));
}
#[test]
fn test_compute_viewport_scrolls_down() {
let (s, e) = crate::tui::compute_viewport(3, 0, 3, 6);
assert_eq!((s, e), (1, 4));
let (s, e) = crate::tui::compute_viewport(4, 1, 3, 6);
assert_eq!((s, e), (2, 5));
}
#[test]
fn test_compute_viewport_scrolls_up() {
let (s, e) = crate::tui::compute_viewport(1, 2, 3, 6);
assert_eq!((s, e), (1, 4));
let (s, e) = crate::tui::compute_viewport(0, 1, 3, 6);
assert_eq!((s, e), (0, 3));
}
#[test]
fn test_compute_viewport_clamps_end_to_total() {
let (s, e) = crate::tui::compute_viewport(5, 3, 3, 6);
assert_eq!((s, e), (3, 6));
}
#[test]
fn test_build_cd_query_strips_duplicate_cd() {
let args = vec![
OsString::from("cd"),
OsString::from("foo"),
OsString::from("bar"),
];
let q = crate::cli::build_cd_query(&args);
assert_eq!(q, "foo bar");
}
#[test]
fn test_build_cd_query_no_change_when_no_dup() {
let args = vec![OsString::from("notes"), OsString::from("proj")];
let q = crate::cli::build_cd_query(&args);
assert_eq!(q, "notes proj");
}
#[test]
fn test_git_uri_parsing_and_dirname() {
use crate::util::{generate_clone_directory_name, parse_git_uri};
let p1 = parse_git_uri("https://github.com/user/repo.git").unwrap();
assert_eq!(p1.user, "user");
assert_eq!(p1.repo, "repo");
let dn = generate_clone_directory_name("git@github.com:user/repo.git", None).unwrap();
assert!(dn.ends_with("-user-repo"));
let dn2 = generate_clone_directory_name("https://gitlab.com/u/r", Some("my-fork"));
assert_eq!(dn2.unwrap(), "my-fork");
}
#[test]
fn test_score_recency_mtime_and_ctime() {
let now = SystemTime::now();
let older = now - Duration::from_secs(10 * 86_400); let recent = now - Duration::from_secs(2 * 3_600);
let s_old_m = crate::score::calculate_score("hello", "", None, Some(older));
let s_new_m = crate::score::calculate_score("hello", "", None, Some(recent));
assert!(s_new_m > s_old_m);
assert!(s_new_m > 0.0);
let s_old_c = crate::score::calculate_score("hello", "", Some(older), None);
let s_new_c = crate::score::calculate_score("hello", "", Some(recent), None);
assert!(s_new_c > s_old_c);
assert!(s_new_c > 0.0);
}
#[test]
fn test_format_relative_time_buckets() {
let now = SystemTime::now();
assert_eq!(crate::tui::format_relative_time(Some(now)), "just now");
assert_eq!(
crate::tui::format_relative_time(Some(now - Duration::from_secs(2 * 60))),
"2m ago"
);
assert_eq!(
crate::tui::format_relative_time(Some(now - Duration::from_secs(3 * 3_600))),
"3h ago"
);
assert_eq!(
crate::tui::format_relative_time(Some(now - Duration::from_secs(5 * 86_400))),
"5d ago"
);
assert_eq!(
crate::tui::format_relative_time(Some(now - Duration::from_secs(2 * 2_592_000))),
"2mo ago"
);
assert_eq!(
crate::tui::format_relative_time(Some(now - Duration::from_secs(3 * 31_536_000))),
"3y ago"
);
assert_eq!(
crate::tui::format_relative_time(Some(now + Duration::from_secs(60))),
"just now"
);
}
#[test]
fn test_display_width_wide_chars() {
assert_eq!(crate::tui::display_width("你好"), 4);
assert_eq!(crate::tui::display_width("ab"), 2);
}
#[test]
fn test_is_git_uri_matrix() {
use crate::util::is_git_uri;
assert!(is_git_uri("https://github.com/user/repo"));
assert!(is_git_uri("git@github.com:user/repo.git"));
assert!(is_git_uri("https://gitlab.com/u/r"));
assert!(is_git_uri("ssh://git@github.com/user/repo.git"));
assert!(!is_git_uri("notes/proj"));
assert!(!is_git_uri("foo"));
assert!(is_git_uri("bar.git"));
}
#[test]
fn test_shellexpand_home_and_join_shell() {
use crate::util::shellexpand_home;
if let Some(home) = dirs::home_dir() {
let p = shellexpand_home("~/abc");
assert!(p.starts_with(&home));
assert!(p.ends_with("abc"));
}
assert_eq!(shellexpand_home("/tmp/x").to_string_lossy(), "/tmp/x");
assert_eq!(crate::util::join_shell(&["a".into(), "b".into()]), "a && b");
}
#[test]
fn test_shell_integration_hint_needed_matrix() {
assert!(crate::shell_integration_hint_needed(false, true, true));
assert!(!crate::shell_integration_hint_needed(true, true, true));
assert!(!crate::shell_integration_hint_needed(false, false, true));
assert!(!crate::shell_integration_hint_needed(false, true, false));
}
#[test]
fn test_render_init_script_posix_uses_wrapped_env_and_routes_subcommands() {
let stdout = crate::render_init_script(
Path::new("/tmp/try"),
Path::new("/tmp/tries"),
crate::ShellKind::Posix,
);
assert!(
stdout.contains("TRY_SHELL_WRAPPED=1"),
"Init script should set the wrapper marker, got: {stdout}"
);
assert!(
stdout.contains("cmd_status=$?"),
"Init script should use cmd_status=$?, got: {stdout}"
);
assert!(
stdout.contains("set -- clone --path \"/tmp/tries\" \"$@\""),
"Init script should route clone through the clone subcommand, got: {stdout}"
);
assert!(
stdout.contains("-h|--help|-V|--version|init"),
"Init script should pass init/help/version through directly, got: {stdout}"
);
let has_standalone_status = stdout
.lines()
.any(|line| line.trim().starts_with("status=$?") || line.contains(" status=$?"));
assert!(
!has_standalone_status,
"Init script should not use status=$? (read-only in zsh), got: {stdout}"
);
}
#[test]
fn test_render_init_script_fish_uses_wrapped_env_and_routes_subcommands() {
let stdout = crate::render_init_script(
Path::new("/tmp/try"),
Path::new("/tmp/tries"),
crate::ShellKind::Fish,
);
assert!(
stdout.contains("TRY_SHELL_WRAPPED=1"),
"Init script should set the wrapper marker, got: {stdout}"
);
assert!(
stdout.contains("set -l cmd_status $status"),
"Init script should use cmd_status in fish, got: {stdout}"
);
assert!(
stdout.contains("case -h --help -V --version init"),
"Init script should pass init/help/version through directly, got: {stdout}"
);
assert!(
stdout.contains("set argv clone --path \"/tmp/tries\" $argv[2..-1]"),
"Init script should route clone through the clone subcommand, got: {stdout}"
);
}
#[test]
fn test_top_level_help_mentions_shell_integration_setup() {
let help = crate::Cli::command().render_help().to_string();
assert!(
help.contains("eval \"$(try init)\""),
"Top-level help should mention shell integration setup, got: {help}"
);
assert!(
help.contains("try cd my-experiment"),
"Top-level help should mention direct binary usage, got: {help}"
);
}
#[test]
fn test_init_help_explains_eval_usage() {
let mut cmd = crate::Cli::command();
let help = cmd
.find_subcommand_mut("init")
.expect("missing init subcommand")
.render_help()
.to_string();
assert!(
help.contains("prints shell code"),
"Init help should explain what the command emits, got: {help}"
);
assert!(
help.contains("eval \"$(try init)\""),
"Init help should mention eval usage, got: {help}"
);
}
}