use std::io::{self, Write};
use std::path::Path;
use clap::Parser;
use repograph_core::{Config, RepographError};
#[derive(Debug, Parser)]
pub struct Args {
#[arg(value_name = "NAME")]
pub name: String,
}
#[tracing::instrument(skip(args, config_dir), fields(name = %args.name))]
pub fn run(args: &Args, config_dir: &Path) -> Result<(), RepographError> {
tracing::debug!(command = "switch", name = %args.name, "start");
let config = Config::load(config_dir)?;
let Some(repo) = config.repos().get(&args.name) else {
let names: Vec<&str> = config.repos().keys().map(String::as_str).collect();
let suggestions = suggest(&args.name, &names);
if !suggestions.is_empty() {
tracing::error!(suggestions = %suggestions.join(", "), "did you mean: {}?", suggestions.join(", "));
}
return Err(RepographError::NotFound {
kind: "repo",
name: args.name.clone(),
});
};
let line = format!("cd {}\n", shell_quote(&repo.path));
let mut stdout = io::stdout().lock();
stdout.write_all(line.as_bytes())?;
stdout.flush()?;
tracing::info!(repo = %args.name, path = %repo.path.display(), "resolved");
Ok(())
}
const NEEDS_QUOTING: &[char] = &[
' ', '\t', '\n', '\'', '"', '$', '\\', '`', '*', '?', '[', ']', '{', '}', '(', ')', ';', '&',
'|', '<', '>', '!', '#', '~',
];
fn shell_quote(path: &Path) -> String {
let s = path.display().to_string();
if !s.chars().any(|c| NEEDS_QUOTING.contains(&c)) {
return s;
}
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
fn suggest(target: &str, candidates: &[&str]) -> Vec<String> {
let mut scored: Vec<(usize, &str)> = candidates
.iter()
.filter_map(|c| {
let d = levenshtein(target, c);
let max_dist = (target.len() / 2).clamp(1, 2);
if d <= 2 && d <= max_dist {
Some((d, *c))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
scored
.into_iter()
.take(3)
.map(|(_, name)| name.to_string())
.collect()
}
fn levenshtein(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
if a_chars.is_empty() {
return b_chars.len();
}
if b_chars.is_empty() {
return a_chars.len();
}
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr: Vec<usize> = vec![0; b_chars.len() + 1];
for (i, ac) in a_chars.iter().enumerate() {
curr[0] = i + 1;
for (j, bc) in b_chars.iter().enumerate() {
let cost = usize::from(ac != bc);
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use std::path::PathBuf;
#[test]
fn shell_quote_unquoted_for_plain_ascii() {
assert_eq!(
shell_quote(&PathBuf::from("/home/user/code/api")),
"/home/user/code/api"
);
assert_eq!(shell_quote(&PathBuf::from("/tmp/x")), "/tmp/x");
}
#[test]
fn shell_quote_single_quotes_when_space_present() {
assert_eq!(
shell_quote(&PathBuf::from("/tmp/has space/repo")),
"'/tmp/has space/repo'"
);
}
#[test]
fn shell_quote_escapes_embedded_single_quote() {
assert_eq!(
shell_quote(&PathBuf::from("/tmp/mike's repo")),
"'/tmp/mike'\\''s repo'"
);
}
#[test]
fn shell_quote_quotes_dollar_sign() {
assert_eq!(shell_quote(&PathBuf::from("/tmp/$work")), "'/tmp/$work'");
}
#[test]
fn shell_quote_quotes_tilde() {
assert_eq!(shell_quote(&PathBuf::from("/tmp/~user")), "'/tmp/~user'");
}
#[test]
fn shell_quote_quotes_backtick() {
assert_eq!(shell_quote(&PathBuf::from("/tmp/`x")), "'/tmp/`x'");
}
#[test]
fn levenshtein_identical() {
assert_eq!(levenshtein("foo", "foo"), 0);
}
#[test]
fn levenshtein_one_edit() {
assert_eq!(levenshtein("api", "app"), 1);
assert_eq!(levenshtein("api", "apis"), 1);
}
#[test]
fn levenshtein_two_edits() {
assert_eq!(levenshtein("foo", "abc"), 3);
assert_eq!(levenshtein("api", "abe"), 2);
}
#[test]
fn levenshtein_empty_string() {
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("", ""), 0);
}
#[test]
fn suggest_returns_near_miss_within_threshold() {
let candidates = ["api", "ui", "lib"];
let s = suggest("app", &candidates);
assert_eq!(s, vec!["api".to_string()]);
}
#[test]
fn suggest_returns_empty_for_no_near_miss() {
let candidates = ["api"];
let s = suggest("zzzz", &candidates);
assert!(s.is_empty(), "no suggestion when distance > threshold");
}
#[test]
fn suggest_ties_break_by_name_ascending() {
let candidates = ["api", "app", "aps"];
let s = suggest("apt", &candidates);
assert_eq!(
s,
vec!["api".to_string(), "app".to_string(), "aps".to_string()]
);
}
#[test]
fn suggest_truncates_to_three() {
let candidates = ["api", "apx", "apy", "apz"];
let s = suggest("ap", &candidates);
assert_eq!(s.len().min(3), s.len(), "max three suggestions");
assert!(s.len() <= 3);
}
#[test]
fn suggest_short_typo_with_only_long_candidates_returns_empty() {
let candidates = ["api"];
let s = suggest("z", &candidates);
assert!(
s.is_empty(),
"short typo gates noisy suggestions against long names"
);
}
}