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