Skip to main content

omni_dev/cli/ai/claude/skills/
common.rs

1//! Shared helpers for skills sync, clean, and status commands.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result};
8use clap::ValueEnum;
9use serde::Serialize;
10
11/// Skills directory name relative to a repository or worktree root.
12pub(super) const SKILLS_SUBPATH: &str = ".claude/skills";
13
14/// Prefix used for entries written to `.git/info/exclude`.
15pub(super) const EXCLUDE_PREFIX: &str = ".claude/skills/";
16
17/// Opening marker for the managed block inside `.git/info/exclude`. Changing
18/// this string would orphan blocks written by prior versions — forward
19/// compatibility commitment.
20pub(super) const BLOCK_BEGIN: &str = "# BEGIN omni-dev-skills (managed — do not edit)";
21
22/// Closing marker for the managed block inside `.git/info/exclude`.
23pub(super) const BLOCK_END: &str = "# END omni-dev-skills";
24
25/// Output format shared by `sync`, `clean`, and `status`.
26#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum OutputFormat {
29    /// Human-readable lines, one per action.
30    #[default]
31    Text,
32    /// Machine-readable YAML document.
33    Yaml,
34}
35
36/// Runs `git rev-parse --show-toplevel` from `path` and returns the absolute root.
37pub(super) fn resolve_toplevel(path: &Path) -> Result<PathBuf> {
38    let output = Command::new("git")
39        .args(["rev-parse", "--show-toplevel"])
40        .current_dir(path)
41        .output()
42        .with_context(|| ctx_spawn_failure("git rev-parse --show-toplevel", path))?;
43    if !output.status.success() {
44        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
45        anyhow::bail!(
46            "git rev-parse --show-toplevel failed in {}: {err}",
47            path.display()
48        );
49    }
50    let stdout = String::from_utf8(output.stdout)
51        .context("git rev-parse --show-toplevel output was not UTF-8")?;
52    Ok(PathBuf::from(stdout.trim()))
53}
54
55/// Runs `git rev-parse --git-common-dir` from `path` and returns an absolute path.
56///
57/// The command may return a relative path (e.g. `.git`) when executed inside the
58/// main worktree; this helper resolves any such relative path against `path`.
59pub(super) fn resolve_git_common_dir(path: &Path) -> Result<PathBuf> {
60    let output = Command::new("git")
61        .args(["rev-parse", "--git-common-dir"])
62        .current_dir(path)
63        .output()
64        .with_context(|| ctx_spawn_failure("git rev-parse --git-common-dir", path))?;
65    if !output.status.success() {
66        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
67        anyhow::bail!(
68            "git rev-parse --git-common-dir failed in {}: {err}",
69            path.display()
70        );
71    }
72    let stdout = String::from_utf8(output.stdout)
73        .context("git rev-parse --git-common-dir output was not UTF-8")?;
74    let raw = PathBuf::from(stdout.trim());
75    if raw.is_absolute() {
76        Ok(raw)
77    } else {
78        Ok(path.join(raw))
79    }
80}
81
82/// Lists all worktree root paths for the repository containing `path`.
83pub(super) fn list_worktrees(path: &Path) -> Result<Vec<PathBuf>> {
84    let output = Command::new("git")
85        .args(["worktree", "list", "--porcelain"])
86        .current_dir(path)
87        .output()
88        .with_context(|| format!("Failed to run git worktree list in {}", path.display()))?;
89    if !output.status.success() {
90        let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
91        anyhow::bail!("git worktree list failed in {}: {err}", path.display());
92    }
93    let stdout =
94        String::from_utf8(output.stdout).context("git worktree list output was not UTF-8")?;
95    Ok(parse_worktree_list(&stdout))
96}
97
98/// Parses porcelain output from `git worktree list --porcelain` into root paths.
99pub(super) fn parse_worktree_list(output: &str) -> Vec<PathBuf> {
100    let mut roots = Vec::new();
101    for line in output.lines() {
102        if let Some(rest) = line.strip_prefix("worktree ") {
103            roots.push(PathBuf::from(rest));
104        }
105    }
106    roots
107}
108
109/// Lists skill directories in `source_skills_dir`, sorted by name.
110pub(super) fn enumerate_skills(source_skills_dir: &Path) -> Result<Vec<(String, PathBuf)>> {
111    let mut skills = Vec::new();
112    if !source_skills_dir.exists() {
113        return Ok(skills);
114    }
115    let entries = fs::read_dir(source_skills_dir)
116        .with_context(|| format!("Failed to read {}", source_skills_dir.display()))?;
117    let dir_label = source_skills_dir.display();
118    for entry in entries {
119        let entry =
120            entry.with_context(|| format!("Failed to read directory entry in {dir_label}"))?;
121        let path = entry.path();
122        if !path.is_dir() {
123            continue;
124        }
125        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
126            continue;
127        };
128        skills.push((name.to_string(), path));
129    }
130    skills.sort_by(|a, b| a.0.cmp(&b.0));
131    Ok(skills)
132}
133
134/// Returns the `.git/info/exclude` path for a target directory, using the common git dir.
135pub(super) fn exclude_file_for(target_root: &Path) -> Result<PathBuf> {
136    let common = resolve_git_common_dir(target_root)?;
137    Ok(common.join("info").join("exclude"))
138}
139
140/// Returns the exclude-file entry for a given skill name.
141pub(super) fn exclude_entry_for(skill_name: &str) -> String {
142    format!("{EXCLUDE_PREFIX}{skill_name}/")
143}
144
145/// Upserts the managed skills block inside `.git/info/exclude`.
146///
147/// Foreign lines outside the block are preserved verbatim. Hand-edits inside
148/// the block are not preserved — the block is rewritten with the union of its
149/// existing entries and `entries`. Returns the entries newly added.
150pub(super) fn upsert_skills_block(
151    exclude_file: &Path,
152    entries: &[String],
153    dry_run: bool,
154) -> Result<Vec<String>> {
155    let content = read_existing_content(exclude_file)?;
156    let lines: Vec<&str> = content.lines().collect();
157    let block = find_block(&lines);
158
159    let existing: Vec<String> = match &block {
160        Some(b) => lines[b.begin + 1..b.end]
161            .iter()
162            .filter(|l| **l != BLOCK_BEGIN && **l != BLOCK_END)
163            .map(|&s| s.to_string())
164            .collect(),
165        None => Vec::new(),
166    };
167
168    let mut additions: Vec<String> = Vec::new();
169    for entry in entries {
170        if !existing.iter().any(|e| e == entry) && !additions.iter().any(|e| e == entry) {
171            additions.push(entry.clone());
172        }
173    }
174
175    if additions.is_empty() || dry_run {
176        return Ok(additions);
177    }
178
179    let mut out_lines: Vec<String> = Vec::new();
180    if let Some(b) = block {
181        out_lines.extend(lines[..b.begin].iter().map(|&s| s.to_string()));
182        out_lines.push(BLOCK_BEGIN.to_string());
183        out_lines.extend(existing.iter().cloned());
184        out_lines.extend(additions.iter().cloned());
185        out_lines.push(BLOCK_END.to_string());
186        out_lines.extend(lines[b.end + 1..].iter().map(|&s| s.to_string()));
187    } else {
188        out_lines.extend(lines.iter().map(|&s| s.to_string()));
189        out_lines.push(BLOCK_BEGIN.to_string());
190        out_lines.extend(additions.iter().cloned());
191        out_lines.push(BLOCK_END.to_string());
192    }
193
194    write_exclude_file(exclude_file, &out_lines)?;
195    Ok(additions)
196}
197
198/// Removes the managed skills block from `.git/info/exclude` entirely.
199///
200/// Returns every line that was inside the block. Foreign lines outside the
201/// block are preserved. No-op (returning empty) if the file or block is absent.
202pub(super) fn remove_skills_block(exclude_file: &Path, dry_run: bool) -> Result<Vec<String>> {
203    if !exclude_file.exists() {
204        return Ok(Vec::new());
205    }
206    let content = fs::read_to_string(exclude_file)
207        .with_context(|| format!("Failed to read {}", exclude_file.display()))?;
208    let lines: Vec<&str> = content.lines().collect();
209    let Some(block) = find_block(&lines) else {
210        return Ok(Vec::new());
211    };
212    let removed: Vec<String> = lines[block.begin + 1..block.end]
213        .iter()
214        .map(|&s| s.to_string())
215        .collect();
216
217    if dry_run {
218        return Ok(removed);
219    }
220
221    let mut out_lines: Vec<String> = Vec::new();
222    out_lines.extend(lines[..block.begin].iter().map(|&s| s.to_string()));
223    out_lines.extend(lines[block.end + 1..].iter().map(|&s| s.to_string()));
224
225    write_exclude_file(exclude_file, &out_lines)?;
226    Ok(removed)
227}
228
229/// Reads the entries currently inside the managed skills block, if any.
230pub(super) fn read_skills_block_entries(exclude_file: &Path) -> Result<Vec<String>> {
231    if !exclude_file.exists() {
232        return Ok(Vec::new());
233    }
234    let content = fs::read_to_string(exclude_file)
235        .with_context(|| format!("Failed to read {}", exclude_file.display()))?;
236    let lines: Vec<&str> = content.lines().collect();
237    let Some(block) = find_block(&lines) else {
238        return Ok(Vec::new());
239    };
240    Ok(lines[block.begin + 1..block.end]
241        .iter()
242        .map(|&s| s.to_string())
243        .collect())
244}
245
246struct BlockBounds {
247    begin: usize,
248    end: usize,
249}
250
251fn find_block(lines: &[&str]) -> Option<BlockBounds> {
252    let begin = lines.iter().position(|l| *l == BLOCK_BEGIN)?;
253    let end_offset = lines[begin + 1..].iter().position(|l| *l == BLOCK_END)?;
254    Some(BlockBounds {
255        begin,
256        end: begin + 1 + end_offset,
257    })
258}
259
260fn read_existing_content(exclude_file: &Path) -> Result<String> {
261    if exclude_file.exists() {
262        fs::read_to_string(exclude_file)
263            .with_context(|| format!("Failed to read {}", exclude_file.display()))
264    } else {
265        Ok(String::new())
266    }
267}
268
269fn write_exclude_file(exclude_file: &Path, lines: &[String]) -> Result<()> {
270    if let Some(parent) = exclude_file.parent() {
271        fs::create_dir_all(parent)
272            .with_context(|| format!("Failed to create {}", parent.display()))?;
273    }
274    let output = if lines.is_empty() {
275        String::new()
276    } else {
277        let mut s = lines.join("\n");
278        s.push('\n');
279        s
280    };
281    fs::write(exclude_file, output)
282        .with_context(|| format!("Failed to write {}", exclude_file.display()))
283}
284
285fn ctx_spawn_failure(command: &str, path: &Path) -> String {
286    format!("Failed to run {command} in {}", path.display())
287}
288
289#[cfg(test)]
290#[allow(clippy::unwrap_used, clippy::expect_used)]
291mod tests {
292    use super::*;
293
294    use tempfile::TempDir;
295
296    fn tempdir() -> TempDir {
297        std::fs::create_dir_all("tmp").ok();
298        TempDir::new_in("tmp").unwrap()
299    }
300
301    fn init_repo(dir: &Path) {
302        let status = Command::new("git")
303            .arg("init")
304            .arg(dir)
305            .output()
306            .expect("git init failed to spawn");
307        assert!(status.status.success(), "git init failed: {status:?}");
308    }
309
310    fn init_repo_with_commit(dir: &Path) {
311        init_repo(dir);
312        fs::write(dir.join("README.md"), "readme").unwrap();
313        for (k, v) in [
314            ("add", vec!["add", "README.md"]),
315            (
316                "commit",
317                vec![
318                    "-c",
319                    "user.email=x@x",
320                    "-c",
321                    "user.name=x",
322                    "commit",
323                    "-q",
324                    "-m",
325                    "init",
326                ],
327            ),
328        ] {
329            let status = Command::new("git")
330                .args(&v)
331                .current_dir(dir)
332                .output()
333                .unwrap_or_else(|_| panic!("git {k} failed to spawn"));
334            assert!(status.status.success(), "git {k} failed: {status:?}");
335        }
336    }
337
338    #[test]
339    fn resolve_toplevel_returns_repo_root() {
340        let dir = tempdir();
341        init_repo(dir.path());
342        let expected = fs::canonicalize(dir.path()).unwrap();
343        let result = resolve_toplevel(dir.path()).unwrap();
344        assert_eq!(fs::canonicalize(result).unwrap(), expected);
345    }
346
347    #[test]
348    fn resolve_toplevel_from_subdir_returns_repo_root() {
349        let dir = tempdir();
350        init_repo(dir.path());
351        let sub = dir.path().join("sub/dir");
352        fs::create_dir_all(&sub).unwrap();
353        let expected = fs::canonicalize(dir.path()).unwrap();
354        let result = resolve_toplevel(&sub).unwrap();
355        assert_eq!(fs::canonicalize(result).unwrap(), expected);
356    }
357
358    #[test]
359    fn resolve_toplevel_outside_repo_fails() {
360        let dir = TempDir::new().unwrap();
361        let err = resolve_toplevel(dir.path()).unwrap_err().to_string();
362        assert!(
363            err.contains("git rev-parse --show-toplevel failed"),
364            "unexpected error: {err}"
365        );
366    }
367
368    #[test]
369    fn resolve_git_common_dir_in_main_worktree() {
370        let dir = tempdir();
371        init_repo(dir.path());
372        let common = resolve_git_common_dir(dir.path()).unwrap();
373        assert!(common.ends_with(".git"), "got {}", common.display());
374    }
375
376    #[test]
377    fn resolve_git_common_dir_from_linked_worktree_points_at_main() {
378        let main = tempdir();
379        init_repo_with_commit(main.path());
380        let wt = tempdir();
381        let linked = wt.path().join("linked");
382        let status = Command::new("git")
383            .args(["worktree", "add", "-q"])
384            .arg(&linked)
385            .current_dir(main.path())
386            .output()
387            .expect("git worktree add failed");
388        assert!(status.status.success(), "git worktree add: {status:?}");
389
390        let common = resolve_git_common_dir(&linked).unwrap();
391        let main_git = fs::canonicalize(main.path().join(".git")).unwrap();
392        assert_eq!(fs::canonicalize(&common).unwrap(), main_git);
393    }
394
395    #[test]
396    fn resolve_git_common_dir_outside_repo_fails() {
397        let dir = TempDir::new().unwrap();
398        let err = resolve_git_common_dir(dir.path()).unwrap_err().to_string();
399        assert!(
400            err.contains("git rev-parse --git-common-dir failed"),
401            "unexpected error: {err}"
402        );
403    }
404
405    #[test]
406    fn list_worktrees_returns_single_for_plain_repo() {
407        let dir = tempdir();
408        init_repo(dir.path());
409        let trees = list_worktrees(dir.path()).unwrap();
410        assert_eq!(trees.len(), 1);
411        assert_eq!(
412            fs::canonicalize(&trees[0]).unwrap(),
413            fs::canonicalize(dir.path()).unwrap()
414        );
415    }
416
417    #[test]
418    fn list_worktrees_returns_multiple_with_linked_worktree() {
419        let main = tempdir();
420        init_repo_with_commit(main.path());
421        let wt = tempdir();
422        let linked = wt.path().join("linked");
423        let status = Command::new("git")
424            .args(["worktree", "add", "-q"])
425            .arg(&linked)
426            .current_dir(main.path())
427            .output()
428            .expect("git worktree add failed");
429        assert!(status.status.success());
430
431        let trees = list_worktrees(main.path()).unwrap();
432        assert_eq!(trees.len(), 2);
433    }
434
435    #[test]
436    fn list_worktrees_outside_repo_fails() {
437        let dir = TempDir::new().unwrap();
438        let err = list_worktrees(dir.path()).unwrap_err().to_string();
439        assert!(
440            err.contains("git worktree list failed"),
441            "unexpected error: {err}"
442        );
443    }
444
445    #[cfg(target_os = "linux")]
446    #[test]
447    fn enumerate_skills_skips_directory_with_non_utf8_name() {
448        use std::ffi::OsStr;
449        use std::os::unix::ffi::OsStrExt;
450
451        let dir = tempdir();
452        let skills = dir.path().join("skills");
453        fs::create_dir_all(&skills).unwrap();
454        fs::create_dir_all(skills.join("alpha")).unwrap();
455        let bad = OsStr::from_bytes(b"bad\xffname");
456        fs::create_dir_all(skills.join(bad)).unwrap();
457
458        let result = enumerate_skills(&skills).unwrap();
459        let names: Vec<_> = result.iter().map(|(n, _)| n.clone()).collect();
460        assert_eq!(names, vec!["alpha"]);
461    }
462
463    #[test]
464    fn exclude_file_for_points_to_info_exclude_under_common_dir() {
465        let dir = tempdir();
466        init_repo(dir.path());
467        let path = exclude_file_for(dir.path()).unwrap();
468        assert!(
469            path.ends_with(".git/info/exclude"),
470            "got {}",
471            path.display()
472        );
473    }
474
475    #[test]
476    fn parse_worktree_list_single() {
477        let out = "worktree /path/to/repo\nHEAD abc123\nbranch refs/heads/main\n";
478        let roots = parse_worktree_list(out);
479        assert_eq!(roots, vec![PathBuf::from("/path/to/repo")]);
480    }
481
482    #[test]
483    fn parse_worktree_list_multiple() {
484        let out = "worktree /a/main\nHEAD abc\nbranch refs/heads/main\n\nworktree /a/feature\nHEAD def\nbranch refs/heads/feature\n";
485        let roots = parse_worktree_list(out);
486        assert_eq!(
487            roots,
488            vec![PathBuf::from("/a/main"), PathBuf::from("/a/feature")]
489        );
490    }
491
492    #[test]
493    fn parse_worktree_list_empty() {
494        assert!(parse_worktree_list("").is_empty());
495    }
496
497    #[test]
498    fn exclude_entry_format() {
499        assert_eq!(exclude_entry_for("review"), ".claude/skills/review/");
500    }
501
502    #[test]
503    fn enumerate_skills_missing_dir_returns_empty() {
504        let dir = tempdir();
505        let result = enumerate_skills(&dir.path().join("missing")).unwrap();
506        assert!(result.is_empty());
507    }
508
509    #[test]
510    fn enumerate_skills_lists_sorted_directories() {
511        let dir = tempdir();
512        let skills_dir = dir.path().join("skills");
513        fs::create_dir_all(skills_dir.join("charlie")).unwrap();
514        fs::create_dir_all(skills_dir.join("alpha")).unwrap();
515        fs::create_dir_all(skills_dir.join("bravo")).unwrap();
516        fs::write(skills_dir.join("README.md"), "hi").unwrap();
517        let result = enumerate_skills(&skills_dir).unwrap();
518        let names: Vec<_> = result.iter().map(|(n, _)| n.clone()).collect();
519        assert_eq!(names, vec!["alpha", "bravo", "charlie"]);
520    }
521
522    #[test]
523    fn upsert_skills_block_creates_block_when_absent() {
524        let dir = tempdir();
525        let exclude = dir.path().join("info").join("exclude");
526        let added = upsert_skills_block(
527            &exclude,
528            &[exclude_entry_for("review"), exclude_entry_for("init")],
529            false,
530        )
531        .unwrap();
532        assert_eq!(
533            added,
534            vec![
535                ".claude/skills/review/".to_string(),
536                ".claude/skills/init/".to_string()
537            ]
538        );
539        let content = fs::read_to_string(&exclude).unwrap();
540        let expected =
541            format!("{BLOCK_BEGIN}\n.claude/skills/review/\n.claude/skills/init/\n{BLOCK_END}\n");
542        assert_eq!(content, expected);
543    }
544
545    #[test]
546    fn upsert_skills_block_appends_to_existing_block() {
547        let dir = tempdir();
548        let exclude = dir.path().join("info").join("exclude");
549        upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
550        let added = upsert_skills_block(&exclude, &[exclude_entry_for("init")], false).unwrap();
551        assert_eq!(added, vec![".claude/skills/init/".to_string()]);
552        let content = fs::read_to_string(&exclude).unwrap();
553        assert!(content.contains(".claude/skills/review/"));
554        assert!(content.contains(".claude/skills/init/"));
555        assert_eq!(content.matches(BLOCK_BEGIN).count(), 1);
556        assert_eq!(content.matches(BLOCK_END).count(), 1);
557    }
558
559    #[test]
560    fn upsert_skills_block_does_not_duplicate() {
561        let dir = tempdir();
562        let exclude = dir.path().join("info").join("exclude");
563        upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
564        let added = upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
565        assert!(added.is_empty());
566        let content = fs::read_to_string(&exclude).unwrap();
567        assert_eq!(content.matches(".claude/skills/review/").count(), 1);
568    }
569
570    #[test]
571    fn upsert_skills_block_dry_run_does_not_write() {
572        let dir = tempdir();
573        let exclude = dir.path().join("info").join("exclude");
574        let added = upsert_skills_block(&exclude, &[exclude_entry_for("review")], true).unwrap();
575        assert_eq!(added, vec![".claude/skills/review/".to_string()]);
576        assert!(!exclude.exists());
577    }
578
579    #[test]
580    fn upsert_skills_block_preserves_foreign_lines_before_and_after() {
581        let dir = tempdir();
582        let exclude = dir.path().join("info").join("exclude");
583        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
584        let original = format!(
585            "# user comment\n*.tmp\n{BLOCK_BEGIN}\n.claude/skills/review/\n{BLOCK_END}\n*.log\n"
586        );
587        fs::write(&exclude, &original).unwrap();
588        upsert_skills_block(&exclude, &[exclude_entry_for("init")], false).unwrap();
589        let content = fs::read_to_string(&exclude).unwrap();
590        assert!(content.starts_with("# user comment\n*.tmp\n"));
591        assert!(content.ends_with("*.log\n"));
592        assert!(content.contains(".claude/skills/review/"));
593        assert!(content.contains(".claude/skills/init/"));
594    }
595
596    #[test]
597    fn upsert_skills_block_returns_empty_when_input_empty() {
598        let dir = tempdir();
599        let exclude = dir.path().join("info").join("exclude");
600        let added = upsert_skills_block(&exclude, &[], false).unwrap();
601        assert!(added.is_empty());
602        assert!(!exclude.exists());
603    }
604
605    #[test]
606    fn upsert_skills_block_dedupes_within_input() {
607        let dir = tempdir();
608        let exclude = dir.path().join("info").join("exclude");
609        let added = upsert_skills_block(
610            &exclude,
611            &[exclude_entry_for("review"), exclude_entry_for("review")],
612            false,
613        )
614        .unwrap();
615        assert_eq!(added.len(), 1);
616        let content = fs::read_to_string(&exclude).unwrap();
617        assert_eq!(content.matches(".claude/skills/review/").count(), 1);
618    }
619
620    #[test]
621    fn upsert_skills_block_appends_after_foreign_lines_in_new_file() {
622        let dir = tempdir();
623        let exclude = dir.path().join("info").join("exclude");
624        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
625        fs::write(&exclude, "*.log\n").unwrap();
626        upsert_skills_block(&exclude, &[exclude_entry_for("review")], false).unwrap();
627        let content = fs::read_to_string(&exclude).unwrap();
628        assert!(content.starts_with("*.log\n"));
629        assert!(content.contains(BLOCK_BEGIN));
630        assert!(content.ends_with(&format!("{BLOCK_END}\n")));
631    }
632
633    #[test]
634    fn upsert_skills_block_propagates_create_dir_all_failure() {
635        let dir = tempdir();
636        let parent_path = dir.path().join("info");
637        fs::write(&parent_path, "block").unwrap();
638        let exclude = parent_path.join("exclude");
639        let err = upsert_skills_block(&exclude, &[exclude_entry_for("a")], false)
640            .unwrap_err()
641            .to_string();
642        assert!(err.contains("Failed to create"), "unexpected error: {err}");
643    }
644
645    #[cfg(unix)]
646    #[test]
647    fn upsert_skills_block_propagates_write_failure() {
648        use std::os::unix::fs::PermissionsExt;
649
650        let dir = tempdir();
651        let info = dir.path().join("info");
652        fs::create_dir_all(&info).unwrap();
653        let exclude = info.join("exclude");
654        let mut perms = fs::metadata(&info).unwrap().permissions();
655        perms.set_mode(0o500);
656        fs::set_permissions(&info, perms).unwrap();
657
658        let result = upsert_skills_block(&exclude, &[exclude_entry_for("a")], false);
659
660        let mut perms = fs::metadata(&info).unwrap().permissions();
661        perms.set_mode(0o700);
662        fs::set_permissions(&info, perms).unwrap();
663
664        let err = result.unwrap_err().to_string();
665        assert!(err.contains("Failed to write"), "unexpected error: {err}");
666    }
667
668    #[test]
669    fn remove_skills_block_removes_block_and_reports_entries() {
670        let dir = tempdir();
671        let exclude = dir.path().join("info").join("exclude");
672        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
673        let content = format!(
674            "# comment\n*.tmp\n{BLOCK_BEGIN}\n.claude/skills/review/\n.claude/skills/init/\n{BLOCK_END}\n"
675        );
676        fs::write(&exclude, &content).unwrap();
677
678        let removed = remove_skills_block(&exclude, false).unwrap();
679        assert_eq!(
680            removed,
681            vec![
682                ".claude/skills/review/".to_string(),
683                ".claude/skills/init/".to_string()
684            ]
685        );
686        let new_content = fs::read_to_string(&exclude).unwrap();
687        assert_eq!(new_content, "# comment\n*.tmp\n");
688    }
689
690    #[test]
691    fn remove_skills_block_missing_file_is_noop() {
692        let dir = tempdir();
693        let exclude = dir.path().join("info").join("exclude");
694        let removed = remove_skills_block(&exclude, false).unwrap();
695        assert!(removed.is_empty());
696    }
697
698    #[test]
699    fn remove_skills_block_missing_block_is_noop() {
700        let dir = tempdir();
701        let exclude = dir.path().join("info").join("exclude");
702        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
703        fs::write(&exclude, "# comment\n*.tmp\n").unwrap();
704        let removed = remove_skills_block(&exclude, false).unwrap();
705        assert!(removed.is_empty());
706        let content = fs::read_to_string(&exclude).unwrap();
707        assert_eq!(content, "# comment\n*.tmp\n");
708    }
709
710    #[test]
711    fn remove_skills_block_dry_run_does_not_modify() {
712        let dir = tempdir();
713        let exclude = dir.path().join("info").join("exclude");
714        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
715        let content = format!("{BLOCK_BEGIN}\n.claude/skills/review/\n{BLOCK_END}\n");
716        fs::write(&exclude, &content).unwrap();
717        let removed = remove_skills_block(&exclude, true).unwrap();
718        assert_eq!(removed, vec![".claude/skills/review/".to_string()]);
719        assert_eq!(fs::read_to_string(&exclude).unwrap(), content);
720    }
721
722    #[test]
723    fn remove_skills_block_empties_file_when_only_block_present() {
724        let dir = tempdir();
725        let exclude = dir.path().join("info").join("exclude");
726        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
727        let content = format!("{BLOCK_BEGIN}\n.claude/skills/review/\n{BLOCK_END}\n");
728        fs::write(&exclude, &content).unwrap();
729        remove_skills_block(&exclude, false).unwrap();
730        let new_content = fs::read_to_string(&exclude).unwrap();
731        assert_eq!(new_content, "");
732    }
733
734    #[cfg(unix)]
735    #[test]
736    fn remove_skills_block_propagates_write_failure() {
737        use std::os::unix::fs::PermissionsExt;
738
739        let dir = tempdir();
740        let info = dir.path().join("info");
741        fs::create_dir_all(&info).unwrap();
742        let exclude = info.join("exclude");
743        fs::write(
744            &exclude,
745            format!("{BLOCK_BEGIN}\n.claude/skills/a/\n{BLOCK_END}\n"),
746        )
747        .unwrap();
748        let mut perms = fs::metadata(&exclude).unwrap().permissions();
749        perms.set_mode(0o400);
750        fs::set_permissions(&exclude, perms).unwrap();
751
752        let result = remove_skills_block(&exclude, false);
753
754        let mut perms = fs::metadata(&exclude).unwrap().permissions();
755        perms.set_mode(0o600);
756        fs::set_permissions(&exclude, perms).unwrap();
757
758        let err = result.unwrap_err().to_string();
759        assert!(err.contains("Failed to write"), "unexpected error: {err}");
760    }
761
762    #[test]
763    fn read_skills_block_entries_returns_entries() {
764        let dir = tempdir();
765        let exclude = dir.path().join("info").join("exclude");
766        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
767        let content = format!(
768            "# comment\n{BLOCK_BEGIN}\n.claude/skills/alpha/\n.claude/skills/bravo/\n{BLOCK_END}\n"
769        );
770        fs::write(&exclude, content).unwrap();
771        let entries = read_skills_block_entries(&exclude).unwrap();
772        assert_eq!(
773            entries,
774            vec![
775                ".claude/skills/alpha/".to_string(),
776                ".claude/skills/bravo/".to_string()
777            ]
778        );
779    }
780
781    #[test]
782    fn read_skills_block_entries_missing_file_returns_empty() {
783        let dir = tempdir();
784        let exclude = dir.path().join("info").join("exclude");
785        let entries = read_skills_block_entries(&exclude).unwrap();
786        assert!(entries.is_empty());
787    }
788
789    #[test]
790    fn read_skills_block_entries_missing_block_returns_empty() {
791        let dir = tempdir();
792        let exclude = dir.path().join("info").join("exclude");
793        fs::create_dir_all(exclude.parent().unwrap()).unwrap();
794        fs::write(&exclude, "*.log\n").unwrap();
795        let entries = read_skills_block_entries(&exclude).unwrap();
796        assert!(entries.is_empty());
797    }
798
799    #[test]
800    fn find_block_requires_both_markers() {
801        let only_begin = vec![BLOCK_BEGIN, ".claude/skills/a/"];
802        assert!(find_block(&only_begin).is_none());
803        let only_end = vec!["foo", BLOCK_END];
804        assert!(find_block(&only_end).is_none());
805    }
806
807    #[test]
808    fn find_block_returns_none_for_reversed_markers() {
809        let reversed = vec![BLOCK_END, "middle", BLOCK_BEGIN];
810        assert!(find_block(&reversed).is_none());
811    }
812
813    #[test]
814    fn resolve_toplevel_propagates_spawn_failure() {
815        let err = resolve_toplevel(Path::new("/this/path/should/not/exist/skills_test_spawn"))
816            .unwrap_err()
817            .to_string();
818        assert!(
819            err.contains("Failed to run git rev-parse --show-toplevel"),
820            "unexpected error: {err}"
821        );
822    }
823
824    #[test]
825    fn resolve_git_common_dir_propagates_spawn_failure() {
826        let err =
827            resolve_git_common_dir(Path::new("/this/path/should/not/exist/skills_test_spawn"))
828                .unwrap_err()
829                .to_string();
830        assert!(
831            err.contains("Failed to run git rev-parse --git-common-dir"),
832            "unexpected error: {err}"
833        );
834    }
835}