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