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 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
55pub(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
82pub(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
98pub(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
109pub(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
134pub(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
140pub(super) fn exclude_entry_for(skill_name: &str) -> String {
142 format!("{EXCLUDE_PREFIX}{skill_name}/")
143}
144
145pub(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
198pub(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
229pub(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}