use std::fmt;
use std::path::Path;
use anyhow::{Context, Result, bail};
use inquire::Select;
use crate::cli::CdArgs;
use crate::commands::ShokaContext;
use crate::config::ShokaConfig;
use crate::state::{Repo, Shelf};
pub const CD_OUT_ENV: &str = "SHOKA_CD_OUT";
pub async fn run(ctx: &ShokaContext, args: CdArgs) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
let shelf = Shelf::load(&ctx.paths)?;
if shelf.is_empty() {
bail!(
"shelf is empty — nothing to cd into. `shoka clone <url>` or \
`shoka import <dir>` first"
);
}
let tag_filtered: Vec<&Repo> = if args.tags.is_empty() {
shelf.repos.iter().collect()
} else {
shelf
.repos
.iter()
.filter(|r| args.tags.iter().all(|t| r.tags.iter().any(|rt| rt == t)))
.collect()
};
if tag_filtered.is_empty() {
bail!(
"no repos matched the tag filter ({} on the shelf total)",
shelf.len()
);
}
let chosen = match args.repo.as_deref() {
Some(hint) => choose_by_hint(&tag_filtered, hint)?,
None => fuzzy_pick(&tag_filtered, "cd to:")?,
};
let path = resolved.clone_path_for_one(chosen)?;
if !path.is_dir() {
bail!(
"{} resolves to {}, but that path doesn't exist — \
the repo was probably moved or deleted; \
try `shoka prune` to clean up the shelf",
chosen.slug(),
path.display()
);
}
emit_path(&path)?;
Ok(())
}
pub fn emit_path(path: &Path) -> Result<()> {
let rendered = path.to_string_lossy();
match std::env::var_os(CD_OUT_ENV) {
Some(out) if !out.is_empty() => {
std::fs::write(&out, rendered.as_bytes()).with_context(|| {
format!(
"writing path to ${CD_OUT_ENV}={}",
Path::new(&out).display()
)
})
}
_ => {
println!("{rendered}");
Ok(())
}
}
}
fn choose_by_hint<'a>(candidates: &[&'a Repo], hint: &str) -> Result<&'a Repo> {
let hint_lc = hint.to_lowercase();
let matches: Vec<&'a Repo> = candidates
.iter()
.copied()
.filter(|r| r.slug().to_lowercase().contains(&hint_lc))
.collect();
match matches.len() {
0 => bail!(
"no repos on the shelf match `{hint}` — \
try `shoka list` to see what's there"
),
1 => Ok(matches[0]),
_ => fuzzy_pick(&matches, &format!("multiple matches for `{hint}`:")),
}
}
fn fuzzy_pick<'a>(candidates: &[&'a Repo], prompt: &str) -> Result<&'a Repo> {
if candidates.is_empty() {
bail!("nothing to pick — candidate list is empty");
}
#[derive(Clone)]
struct RepoItem<'r>(&'r Repo);
impl fmt::Display for RepoItem<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0.slug())?;
if let Some(p) = &self.0.path {
write!(f, " → {}", tilde_shorten(p))?;
}
Ok(())
}
}
let items: Vec<RepoItem<'a>> = candidates.iter().copied().map(RepoItem).collect();
let chosen = Select::new(prompt, items)
.prompt()
.context("repo selection cancelled")?;
Ok(chosen.0)
}
fn tilde_shorten(p: &Path) -> String {
static HOME: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
let home =
HOME.get_or_init(|| directories::BaseDirs::new().map(|b| b.home_dir().to_path_buf()));
match home {
Some(h) => match p.strip_prefix(h) {
Ok(rest) => {
let sep = std::path::MAIN_SEPARATOR;
if rest.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~{sep}{}", rest.display())
}
}
Err(_) => p.display().to_string(),
},
None => p.display().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{GlobalConfig, VcsDefault};
use std::collections::BTreeMap;
fn r(name: &str) -> Repo {
Repo::new("github.com", "yukimemi", name)
}
fn r_owned(owner: &str, name: &str) -> Repo {
Repo::new("github.com", owner, name)
}
#[test]
fn hint_filters_to_substring_case_insensitive() {
let a = r("shoka");
let b = r("renri");
let c = r("kanade");
let candidates: Vec<&Repo> = vec![&a, &b, &c];
let picked = choose_by_hint(&candidates, "ren").unwrap();
assert_eq!(picked.name, "renri");
let picked = choose_by_hint(&candidates, "RENRI").unwrap();
assert_eq!(picked.name, "renri");
}
#[test]
fn hint_with_zero_matches_errors_cleanly() {
let a = r("shoka");
let candidates: Vec<&Repo> = vec![&a];
let err = choose_by_hint(&candidates, "no-such-thing").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no repos on the shelf match"),
"expected no-match error, got: {msg}"
);
}
#[test]
fn hint_substring_matches_owner_or_host() {
let a = r_owned("rust-org", "alpha");
let b = r_owned("other-org", "beta");
let candidates: Vec<&Repo> = vec![&a, &b];
let picked = choose_by_hint(&candidates, "rust-org").unwrap();
assert_eq!(picked.name, "alpha");
}
fn resolved_with_layout(layout: &str, root: &str) -> crate::config::ResolvedConfig {
ShokaConfig {
global: GlobalConfig {
root: Some(root.into()),
layout: layout.into(),
..Default::default()
},
routes: vec![],
profiles: BTreeMap::new(),
}
.resolve(None)
.expect("resolve")
}
#[test]
fn tilde_shorten_collapses_home_dir_prefix() {
let home = match directories::BaseDirs::new() {
Some(b) => b.home_dir().to_path_buf(),
None => return, };
let sep = std::path::MAIN_SEPARATOR;
assert_eq!(tilde_shorten(&home), "~");
let nested = home
.join("src")
.join("github.com")
.join("yukimemi")
.join("shoka");
let rendered = tilde_shorten(&nested);
assert!(
rendered.starts_with(&format!("~{sep}src")),
"expected `~{sep}src…`, got {rendered:?}"
);
let outside = std::path::PathBuf::from(if cfg!(windows) {
r"C:\definitely\not\home"
} else {
"/definitely/not/home"
});
let rendered = tilde_shorten(&outside);
assert!(
!rendered.starts_with('~'),
"outside-home path must not tilde-shorten, got {rendered:?}"
);
}
#[test]
fn clone_path_uses_layout_so_cd_lands_where_clone_left_it() {
let r = r("shoka");
let resolved = resolved_with_layout("{{ root }}/{{ name }}", "/data");
let p = resolved.clone_path_for_one(&r).unwrap();
let s = p.to_string_lossy().replace('\\', "/");
assert!(
s.ends_with("/data/shoka"),
"cd path should follow the configured layout, got {s:?}"
);
let _ = VcsDefault::Auto;
}
}