use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::Parser;
use serde::Serialize;
use super::common::{
exclude_file_for, list_worktrees, read_skills_block_entries, resolve_toplevel, OutputFormat,
SKILLS_SUBPATH,
};
#[derive(Parser)]
pub struct StatusCommand {
#[arg(long, value_name = "PATH")]
pub target: Option<PathBuf>,
#[arg(long)]
pub worktrees: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub(super) format: OutputFormat,
}
impl StatusCommand {
pub fn execute(self) -> Result<()> {
let cwd = std::env::current_dir().context("Failed to determine current directory")?;
let target_seed = self.target.clone().unwrap_or(cwd);
let target_root = resolve_toplevel(&target_seed)?;
let mut targets = vec![target_root.clone()];
if self.worktrees {
for wt in list_worktrees(&target_root)? {
if !targets.iter().any(|t| t == &wt) {
targets.push(wt);
}
}
}
let report = run_status(&targets)?;
print_report(&report, self.format)?;
Ok(())
}
}
#[derive(Debug, Default, Serialize)]
pub(super) struct StatusReport {
pub targets: Vec<TargetStatus>,
}
impl StatusReport {
fn is_empty(&self) -> bool {
self.targets
.iter()
.all(|t| t.symlinks.is_empty() && t.exclude_entries.is_empty())
}
}
#[derive(Debug, Serialize)]
pub(super) struct TargetStatus {
pub root: PathBuf,
pub symlinks: Vec<SymlinkInfo>,
pub exclude_file: PathBuf,
pub exclude_entries: Vec<String>,
}
#[derive(Debug, Serialize)]
pub(super) struct SymlinkInfo {
pub path: PathBuf,
pub points_to: PathBuf,
}
pub(super) fn run_status(targets: &[PathBuf]) -> Result<StatusReport> {
let mut report = StatusReport::default();
for target_root in targets {
report.targets.push(collect_target_status(target_root)?);
}
Ok(report)
}
fn collect_target_status(target_root: &Path) -> Result<TargetStatus> {
let skills_dir = target_root.join(SKILLS_SUBPATH);
let symlinks = if skills_dir.exists() {
collect_symlinks(&skills_dir)?
} else {
Vec::new()
};
let exclude_file = exclude_file_for(target_root)?;
let exclude_entries = read_skills_block_entries(&exclude_file)?;
Ok(TargetStatus {
root: target_root.to_path_buf(),
symlinks,
exclude_file,
exclude_entries,
})
}
fn collect_symlinks(skills_dir: &Path) -> Result<Vec<SymlinkInfo>> {
let mut out = Vec::new();
let entries = fs::read_dir(skills_dir)
.with_context(|| format!("Failed to read {}", skills_dir.display()))?;
for entry in entries {
let entry =
entry.with_context(|| format!("Failed to read entry in {}", skills_dir.display()))?;
let path = entry.path();
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("Failed to inspect {}", path.display()))?;
if !meta.file_type().is_symlink() {
continue;
}
let points_to = fs::read_link(&path)
.with_context(|| format!("Failed to read link {}", path.display()))?;
out.push(SymlinkInfo { path, points_to });
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
#[derive(Serialize)]
struct StatusOutput<'a> {
targets: &'a [TargetStatus],
}
fn print_report(report: &StatusReport, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Text => {
if report.is_empty() {
return Ok(());
}
print_report_text(report);
Ok(())
}
OutputFormat::Yaml => {
let output = StatusOutput {
targets: &report.targets,
};
let yaml = serde_yaml::to_string(&output)
.context("Failed to serialize status report as YAML")?;
print!("{yaml}");
Ok(())
}
}
}
fn print_report_text(report: &StatusReport) {
let mut first = true;
for target in &report.targets {
if target.symlinks.is_empty() && target.exclude_entries.is_empty() {
continue;
}
if !first {
println!();
}
first = false;
println!("{}", target.root.display());
if !target.symlinks.is_empty() {
println!(" symlinks:");
for link in &target.symlinks {
println!(
" {} -> {}",
link.path.display(),
link.points_to.display()
);
}
}
if !target.exclude_entries.is_empty() {
println!(" exclude block ({}):", target.exclude_file.display());
for entry in &target.exclude_entries {
println!(" {entry}");
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use super::super::common::{BLOCK_BEGIN, BLOCK_END};
use tempfile::TempDir;
fn tempdir() -> TempDir {
std::fs::create_dir_all("tmp").ok();
TempDir::new_in("tmp").unwrap()
}
fn init_repo(dir: &Path) {
let status = std::process::Command::new("git")
.arg("init")
.arg(dir)
.output()
.expect("git init failed to spawn");
assert!(status.status.success(), "git init failed: {status:?}");
}
fn init_repo_with_commit(dir: &Path) {
init_repo(dir);
fs::write(dir.join("README.md"), "readme").unwrap();
let add = std::process::Command::new("git")
.args(["add", "README.md"])
.current_dir(dir)
.output()
.unwrap();
assert!(add.status.success());
let commit = std::process::Command::new("git")
.args([
"-c",
"user.email=x@x",
"-c",
"user.name=x",
"commit",
"-q",
"-m",
"init",
])
.current_dir(dir)
.output()
.unwrap();
assert!(commit.status.success());
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
fn write_block(exclude: &Path, entries: &[&str]) {
fs::create_dir_all(exclude.parent().unwrap()).unwrap();
let mut content = format!("{BLOCK_BEGIN}\n");
for e in entries {
content.push_str(e);
content.push('\n');
}
content.push_str(BLOCK_END);
content.push('\n');
fs::write(exclude, content).unwrap();
}
#[test]
fn run_status_reports_symlinks_and_block_entries() {
let src = tempdir();
let tgt = tempdir();
init_repo(tgt.path());
let source_skill = src.path().join("alpha");
fs::create_dir_all(&source_skill).unwrap();
let skills_dir = tgt.path().join(SKILLS_SUBPATH);
fs::create_dir_all(&skills_dir).unwrap();
symlink(&source_skill, &skills_dir.join("alpha")).unwrap();
write_block(
&tgt.path().join(".git/info/exclude"),
&[".claude/skills/alpha/"],
);
let report = run_status(&[tgt.path().to_path_buf()]).unwrap();
assert_eq!(report.targets.len(), 1);
let t = &report.targets[0];
assert_eq!(t.symlinks.len(), 1);
assert_eq!(t.symlinks[0].path, skills_dir.join("alpha"));
assert_eq!(t.symlinks[0].points_to, source_skill);
assert_eq!(t.exclude_entries, vec![".claude/skills/alpha/".to_string()]);
}
#[test]
fn run_status_empty_repo_is_empty() {
let tgt = tempdir();
init_repo(tgt.path());
let report = run_status(&[tgt.path().to_path_buf()]).unwrap();
assert!(report.is_empty());
assert_eq!(report.targets.len(), 1);
assert!(report.targets[0].symlinks.is_empty());
assert!(report.targets[0].exclude_entries.is_empty());
}
#[test]
fn run_status_skips_real_files_in_skills_dir() {
let tgt = tempdir();
init_repo(tgt.path());
let skills_dir = tgt.path().join(SKILLS_SUBPATH);
fs::create_dir_all(&skills_dir).unwrap();
fs::write(skills_dir.join("README.md"), "hi").unwrap();
fs::create_dir_all(skills_dir.join("real-skill")).unwrap();
let report = run_status(&[tgt.path().to_path_buf()]).unwrap();
assert!(report.targets[0].symlinks.is_empty());
}
#[test]
fn execute_target_defaults_to_cwd_is_quiet_on_clean_repo() {
let tgt = tempdir();
init_repo(tgt.path());
let cmd = StatusCommand {
target: Some(tgt.path().to_path_buf()),
worktrees: false,
format: OutputFormat::Text,
};
cmd.execute().unwrap();
}
#[test]
fn execute_text_output_prints_residue() {
let src = tempdir();
let tgt = tempdir();
init_repo(tgt.path());
let source_skill = src.path().join("alpha");
fs::create_dir_all(&source_skill).unwrap();
let skills_dir = tgt.path().join(SKILLS_SUBPATH);
fs::create_dir_all(&skills_dir).unwrap();
symlink(&source_skill, &skills_dir.join("alpha")).unwrap();
write_block(
&tgt.path().join(".git/info/exclude"),
&[".claude/skills/alpha/"],
);
let cmd = StatusCommand {
target: Some(tgt.path().to_path_buf()),
worktrees: false,
format: OutputFormat::Text,
};
cmd.execute().unwrap();
}
#[test]
fn execute_yaml_format_serializes_report() {
let src = tempdir();
let tgt = tempdir();
init_repo(tgt.path());
let source_skill = src.path().join("alpha");
fs::create_dir_all(&source_skill).unwrap();
let skills_dir = tgt.path().join(SKILLS_SUBPATH);
fs::create_dir_all(&skills_dir).unwrap();
symlink(&source_skill, &skills_dir.join("alpha")).unwrap();
write_block(
&tgt.path().join(".git/info/exclude"),
&[".claude/skills/alpha/"],
);
let cmd = StatusCommand {
target: Some(tgt.path().to_path_buf()),
worktrees: false,
format: OutputFormat::Yaml,
};
cmd.execute().unwrap();
}
#[test]
fn execute_with_worktrees_aggregates_all_worktrees() {
let tgt_main = tempdir();
let wt_parent = tempdir();
let linked = wt_parent.path().join("linked");
init_repo_with_commit(tgt_main.path());
let add_wt = std::process::Command::new("git")
.args(["worktree", "add", "-q"])
.arg(&linked)
.current_dir(tgt_main.path())
.output()
.unwrap();
assert!(add_wt.status.success(), "git worktree add: {add_wt:?}");
let src = tempdir();
let source_skill = src.path().join("alpha");
fs::create_dir_all(&source_skill).unwrap();
for root in [tgt_main.path(), linked.as_path()] {
let skills_dir = root.join(SKILLS_SUBPATH);
fs::create_dir_all(&skills_dir).unwrap();
symlink(&source_skill, &skills_dir.join("alpha")).unwrap();
}
write_block(
&tgt_main.path().join(".git/info/exclude"),
&[".claude/skills/alpha/"],
);
let cmd = StatusCommand {
target: Some(tgt_main.path().to_path_buf()),
worktrees: true,
format: OutputFormat::Text,
};
cmd.execute().unwrap();
}
#[test]
fn print_report_yaml_serializes_fields() {
let report = StatusReport {
targets: vec![TargetStatus {
root: PathBuf::from("/repo"),
symlinks: vec![SymlinkInfo {
path: PathBuf::from("/repo/.claude/skills/alpha"),
points_to: PathBuf::from("/src/.claude/skills/alpha"),
}],
exclude_file: PathBuf::from("/repo/.git/info/exclude"),
exclude_entries: vec![".claude/skills/alpha/".to_string()],
}],
};
let output = StatusOutput {
targets: &report.targets,
};
let yaml = serde_yaml::to_string(&output).unwrap();
assert!(yaml.contains("root: /repo"));
assert!(yaml.contains("points_to:"));
assert!(yaml.contains(".claude/skills/alpha/"));
}
#[test]
fn print_report_text_covers_multiple_targets_and_mixed_empty() {
let report = StatusReport {
targets: vec![
TargetStatus {
root: PathBuf::from("/empty"),
symlinks: Vec::new(),
exclude_file: PathBuf::from("/empty/.git/info/exclude"),
exclude_entries: Vec::new(),
},
TargetStatus {
root: PathBuf::from("/only-symlinks"),
symlinks: vec![SymlinkInfo {
path: PathBuf::from("/only-symlinks/.claude/skills/alpha"),
points_to: PathBuf::from("/src/alpha"),
}],
exclude_file: PathBuf::from("/only-symlinks/.git/info/exclude"),
exclude_entries: Vec::new(),
},
TargetStatus {
root: PathBuf::from("/only-entries"),
symlinks: Vec::new(),
exclude_file: PathBuf::from("/only-entries/.git/info/exclude"),
exclude_entries: vec![".claude/skills/alpha/".to_string()],
},
TargetStatus {
root: PathBuf::from("/both"),
symlinks: vec![SymlinkInfo {
path: PathBuf::from("/both/.claude/skills/bravo"),
points_to: PathBuf::from("/src/bravo"),
}],
exclude_file: PathBuf::from("/both/.git/info/exclude"),
exclude_entries: vec![".claude/skills/bravo/".to_string()],
},
],
};
print_report_text(&report);
}
#[test]
fn status_report_is_empty_only_when_all_targets_empty() {
let empty = StatusReport {
targets: vec![TargetStatus {
root: PathBuf::from("/a"),
symlinks: Vec::new(),
exclude_file: PathBuf::from("/a/.git/info/exclude"),
exclude_entries: Vec::new(),
}],
};
assert!(empty.is_empty());
let not_empty = StatusReport {
targets: vec![TargetStatus {
root: PathBuf::from("/a"),
symlinks: Vec::new(),
exclude_file: PathBuf::from("/a/.git/info/exclude"),
exclude_entries: vec![".claude/skills/x/".to_string()],
}],
};
assert!(!not_empty.is_empty());
}
}