spec_driven_docs/services/
skill_installer.rs1use camino::{Utf8Path, Utf8PathBuf};
10
11use crate::error::AppError;
12
13struct Planned {
15 destination: Utf8PathBuf,
16 bytes: &'static [u8],
17}
18
19fn plan(roots: &[Utf8PathBuf]) -> Result<Vec<Planned>, AppError> {
20 let mut planned = Vec::new();
21 for root in roots {
22 for name in crate::embedded::skill_names() {
23 let text = crate::embedded::skill(name)
24 .ok_or_else(|| anyhow::anyhow!("payload skill missing: {name}"))?;
25 planned.push(Planned {
26 destination: root.join(name).join("SKILL.md"),
27 bytes: text.as_bytes(),
28 });
29 }
30 }
31 Ok(planned)
32}
33
34fn check_destination(destination: &Utf8Path) -> Result<(), AppError> {
35 if destination.is_symlink() {
36 return Err(AppError::Refused(format!(
37 "destination is a symlink: {destination}"
38 )));
39 }
40 if destination.exists() && !destination.is_file() {
41 return Err(AppError::Refused(format!(
42 "destination exists and is not a regular file: {destination}"
43 )));
44 }
45 Ok(())
46}
47
48pub fn install(roots: &[Utf8PathBuf], apply: bool, force: bool) -> Result<Vec<String>, AppError> {
56 let planned = plan(roots)?;
57 let mut lines: Vec<String> = Vec::new();
58 for entry in &planned {
59 check_destination(&entry.destination)?;
60 lines.push(entry.destination.to_string());
61 }
62 if !apply {
63 lines.push("DRY RUN: no files written".to_string());
64 return Ok(lines);
65 }
66 if !force {
67 let mut conflicts: Vec<String> = Vec::new();
68 for entry in &planned {
69 if entry.destination.is_file() && std::fs::read(&entry.destination)? != entry.bytes {
72 conflicts.push(entry.destination.to_string());
73 }
74 }
75 if !conflicts.is_empty() {
76 return Err(AppError::Refused(format!(
77 "destinations hold locally changed bytes: {}; re-run with --force to overwrite",
78 conflicts.join(", ")
79 )));
80 }
81 }
82 for entry in &planned {
83 crate::adapters::fs::write_file(&entry.destination, entry.bytes)?;
84 }
85 Ok(lines)
86}
87
88pub fn uninstall(roots: &[Utf8PathBuf], apply: bool) -> Result<Vec<String>, AppError> {
99 let mut lines: Vec<String> = Vec::new();
100 let mut removable: Vec<Utf8PathBuf> = Vec::new();
101 for root in roots {
102 for name in crate::embedded::skill_names() {
103 let destination = root.join(name).join("SKILL.md");
104 check_destination(&destination)?;
105 if destination.is_file() {
106 lines.push(destination.to_string());
107 removable.push(destination);
108 }
109 }
110 }
111 if !apply {
112 lines.push("DRY RUN: no files removed".to_string());
113 return Ok(lines);
114 }
115 for destination in &removable {
116 std::fs::remove_file(destination)?;
117 let directory = destination
118 .parent()
119 .ok_or_else(|| anyhow::anyhow!("destination has no parent: {destination}"))?;
120 if std::fs::read_dir(directory)?.next().is_none() {
121 std::fs::remove_dir(directory)?;
122 } else {
123 lines.push(format!("kept (not empty): {directory}"));
124 }
125 }
126 Ok(lines)
127}
128
129#[cfg(test)]
130mod tests {
131 #![allow(clippy::unwrap_used)]
132
133 use super::*;
134
135 fn root(dir: &tempfile::TempDir) -> Utf8PathBuf {
136 Utf8PathBuf::from(dir.path().to_str().unwrap())
137 }
138
139 #[test]
140 fn a_preview_lists_every_destination_and_writes_nothing() {
141 let dir = tempfile::tempdir().unwrap();
142 let skills = root(&dir).join(".claude/skills");
143 let lines = install(std::slice::from_ref(&skills), false, false).unwrap();
144 assert_eq!(lines.last().unwrap(), "DRY RUN: no files written");
145 assert_eq!(lines.len(), crate::embedded::skill_names().len() + 1);
146 assert!(!skills.exists());
147 }
148
149 #[test]
150 fn an_apply_is_idempotent_and_a_conflict_refuses_with_every_path() {
151 let dir = tempfile::tempdir().unwrap();
152 let skills = root(&dir).join(".agents/skills");
153 install(std::slice::from_ref(&skills), true, false).unwrap();
154 install(std::slice::from_ref(&skills), true, false).unwrap();
155 for name in crate::embedded::skill_names() {
156 std::fs::write(skills.join(name).join("SKILL.md"), "edited").unwrap();
157 }
158 let error = install(std::slice::from_ref(&skills), true, false).unwrap_err();
159 let message = error.to_string();
160 for name in crate::embedded::skill_names() {
161 assert!(message.contains(name), "{message} misses {name}");
162 }
163 install(std::slice::from_ref(&skills), true, true).unwrap();
164 let text = std::fs::read_to_string(skills.join("sdd-setup/SKILL.md")).unwrap();
165 assert!(text.contains("name: sdd-setup"));
166 }
167
168 #[test]
169 fn an_uninstall_removes_only_payload_files_and_keeps_foreign_ones() {
170 let dir = tempfile::tempdir().unwrap();
171 let skills = root(&dir).join(".claude/skills");
172 install(std::slice::from_ref(&skills), true, false).unwrap();
173 std::fs::write(skills.join("sdd-setup/notes.md"), "mine").unwrap();
174
175 let preview = uninstall(std::slice::from_ref(&skills), false).unwrap();
176 assert_eq!(preview.last().unwrap(), "DRY RUN: no files removed");
177 assert!(skills.join("sdd-setup/SKILL.md").is_file());
178
179 let lines = uninstall(std::slice::from_ref(&skills), true).unwrap();
180 assert!(!skills.join("sdd-setup/SKILL.md").exists());
181 assert!(!skills.join("sdd-write-docs").exists());
182 assert_eq!(
183 std::fs::read_to_string(skills.join("sdd-setup/notes.md")).unwrap(),
184 "mine"
185 );
186 assert!(
187 lines
188 .iter()
189 .any(|line| line.starts_with("kept (not empty):"))
190 );
191
192 uninstall(std::slice::from_ref(&skills), true).unwrap();
194 }
195}