spec_driven_docs/services/
installer.rs1use std::collections::BTreeMap;
12
13use camino::{Utf8Path, Utf8PathBuf};
14
15use crate::adapters::fs::{DestinationRefusal, check_destination, write_file};
16use crate::domain::manifest::{CANON_SOURCE, MANIFEST_PATH, Manifest, SCHEMA_VERSION};
17use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
18use crate::domain::profile::{ProfileId, resolve_destination};
19use crate::domain::version::CanonVersion;
20use crate::error::AppError;
21use crate::services::hooks_render::{RenderOptions, render_block};
22use crate::services::verifier;
23
24#[derive(Debug, Clone)]
26pub struct InitOptions {
27 pub target: Utf8PathBuf,
29 pub profile: ProfileId,
31 pub apply: bool,
33 pub dry_run: bool,
35}
36
37#[derive(Debug)]
39pub struct InitOutcome {
40 pub lines: Vec<String>,
42 pub applied: bool,
44}
45
46fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
47 if !target.is_absolute() {
48 return Err(AppError::Usage("target must be absolute".to_string()));
49 }
50 if !target.is_dir() {
51 return Err(AppError::Usage(format!("unresolved target: {target}")));
52 }
53 let canonical = std::fs::canonicalize(target)?;
54 let canonical = Utf8PathBuf::from_path_buf(canonical)
55 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
56 if canonical.as_str().chars().all(|c| c == '/') {
57 return Err(AppError::Usage("refusing root target".to_string()));
58 }
59 let mut ancestor = Some(canonical.as_path());
60 while let Some(dir) = ancestor {
61 if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
62 && cargo.contains("name = \"spec-driven-docs\"")
63 {
64 return Err(AppError::Usage(
65 "target is inside the canon checkout".to_string(),
66 ));
67 }
68 ancestor = dir.parent();
69 }
70 Ok(canonical)
71}
72
73fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
74 for entry in target.read_dir_utf8()? {
75 let entry = entry?;
76 if entry.file_name() != ".git" {
77 return Ok(true);
78 }
79 }
80 Ok(false)
81}
82
83fn installed_at(target: &Utf8Path) -> String {
84 std::fs::read_to_string(target.join(MANIFEST_PATH))
85 .ok()
86 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
87 .and_then(|value| {
88 value
89 .get("installed_at")
90 .and_then(|v| v.as_str())
91 .map(String::from)
92 })
93 .unwrap_or_else(|| {
94 jiff::Timestamp::now()
95 .strftime("%Y-%m-%dT%H:%M:%SZ")
96 .to_string()
97 })
98}
99
100struct TargetState {
101 files: Vec<(Utf8PathBuf, Vec<u8>)>,
102 lines: Vec<String>,
103}
104
105#[allow(clippy::too_many_lines)]
106fn compute_target_state(target: &Utf8Path, profile: ProfileId) -> Result<TargetState, AppError> {
107 let declaration = profile.profile();
108 let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
109 let mut lines = Vec::new();
110 let mut managed_entries = Vec::new();
111 let mut adopted_entries = Vec::new();
112
113 for projection in declaration.managed {
114 let bytes = crate::embedded::asset(projection.source)
115 .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
116 let destination = Utf8PathBuf::from(projection.destination);
117 managed_entries.push(ManagedEntry {
118 source: projection.source.into(),
119 destination: destination.clone(),
120 sha256: Sha256::of(bytes),
121 });
122 lines.push(destination.to_string());
123 files.push((destination, bytes.to_vec()));
124 }
125
126 for projection in declaration.adopted {
127 let seed = crate::embedded::asset(projection.source)
128 .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
129 let destination = resolve_destination(projection.destination, declaration.docs_root);
130 let existing = target.join(&destination);
131 let bytes = if existing.is_file() {
132 std::fs::read(&existing)?
133 } else {
134 seed.to_vec()
135 };
136 adopted_entries.push(AdoptedEntry {
137 source: projection.source.into(),
138 destination: destination.clone(),
139 sha256: Sha256::of(&bytes),
140 baseline_sha256: Sha256::of(seed),
141 });
142 lines.push(destination.to_string());
143 files.push((destination, bytes));
144 }
145
146 let config_path = target.join(".pre-commit-config.yaml");
147 let host = if config_path.is_file() {
148 std::fs::read_to_string(&config_path)?
149 } else {
150 "repos:\n".to_string()
151 };
152 let (base, _) = crate::domain::marker::split_block(&host)?;
153 let indent = crate::domain::marker::splice_indent(&base)?;
154 let block = render_block(&RenderOptions {
155 docs_root: declaration.docs_root.to_string(),
156 indent,
157 ..RenderOptions::default()
158 });
159 let spliced = crate::domain::marker::splice(&base, &block)?;
160 let marker_hash = crate::domain::marker::block_hash(&spliced)
161 .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
162 lines.push(".pre-commit-config.yaml".to_string());
163 files.push((
164 Utf8PathBuf::from(".pre-commit-config.yaml"),
165 spliced.into_bytes(),
166 ));
167
168 let mut integration_blocks = vec![IntegrationBlock {
169 path: ".pre-commit-config.yaml".into(),
170 marker_hash,
171 }];
172
173 let agents_relative = Utf8Path::new("AGENTS.md");
177 if target.join(agents_relative).is_symlink() {
178 return Err(AppError::Refused(
179 "AGENTS.md is a symlink; refusing to write the documentation block through it"
180 .to_string(),
181 ));
182 }
183 let agents_host = if target.join(agents_relative).is_file() {
184 std::fs::read_to_string(target.join(agents_relative))?
185 } else {
186 String::new()
187 };
188 let agents_block =
189 crate::services::agents_render::render_block(&declaration.docs_root.to_string());
190 let agents = crate::domain::marker::place_agents_block(&agents_host, &agents_block)?;
191 let agents_hash = crate::domain::marker::block_hash_with(
192 &agents,
193 crate::domain::marker::AGENTS_BEGIN,
194 crate::domain::marker::AGENTS_END,
195 )
196 .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
197 if agents_host.contains("## Documentation")
200 && crate::domain::marker::block_region_with(
201 &agents_host,
202 crate::domain::marker::AGENTS_BEGIN,
203 crate::domain::marker::AGENTS_END,
204 )
205 .is_none()
206 {
207 lines.push(
208 "note: AGENTS.md carries an unmarked '## Documentation' section; the managed block was appended and the old section left in place — remove it by hand".to_string(),
209 );
210 }
211 lines.push("AGENTS.md".to_string());
212 files.push((agents_relative.to_path_buf(), agents.into_bytes()));
213 integration_blocks.push(IntegrationBlock {
214 path: "AGENTS.md".into(),
215 marker_hash: agents_hash,
216 });
217
218 let manifest = Manifest {
219 schema_version: SCHEMA_VERSION,
220 canon_version: CanonVersion::current(),
221 canon_source: CANON_SOURCE.to_string(),
222 profile,
223 docs_root: declaration.docs_root,
224 installed_at: installed_at(target),
225 managed_files: managed_entries,
226 adopted_files: adopted_entries,
227 integration_blocks,
228 };
229 lines.push(MANIFEST_PATH.to_string());
230 files.push((
231 Utf8PathBuf::from(MANIFEST_PATH),
232 manifest.to_json().into_bytes(),
233 ));
234
235 Ok(TargetState { files, lines })
236}
237
238fn refusal_line(destination: &Utf8Path, refusal: &DestinationRefusal) -> String {
239 match refusal {
240 DestinationRefusal::SymlinkEscape => {
241 format!("destination escapes the target through a symlink: {destination}")
242 }
243 DestinationRefusal::FileBlocksDirectory(blocked) => {
244 format!("a file blocks a directory the install needs: {blocked}")
245 }
246 DestinationRefusal::NotARegularFile => {
247 format!("destination exists and is not a regular file: {destination}")
248 }
249 }
250}
251
252fn apply(target: &Utf8Path, state: &TargetState) -> Result<(), AppError> {
253 let mut ordered: Vec<&(Utf8PathBuf, Vec<u8>)> = state.files.iter().collect();
254 ordered.sort_by(|a, b| a.0.as_str().as_bytes().cmp(b.0.as_str().as_bytes()));
255
256 for (destination, _) in &ordered {
257 check_destination(target, destination)
258 .map_err(|refusal| AppError::Refused(refusal_line(destination, &refusal)))?;
259 }
260
261 let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
262 let rollback = |backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>| -> Vec<Utf8PathBuf> {
263 let mut unrestored = Vec::new();
264 for (destination, previous) in backups {
265 let full = target.join(destination);
266 let restored = previous.as_ref().map_or_else(
267 || std::fs::remove_file(&full).is_ok() || !full.exists(),
268 |bytes| write_file(&full, bytes).is_ok(),
269 );
270 if !restored {
271 unrestored.push(destination.clone());
272 }
273 }
274 unrestored
275 };
276 let abort = |unrestored: Vec<Utf8PathBuf>, cause: &str| {
280 if unrestored.is_empty() {
281 AppError::Refused(format!("apply aborted; the target was restored: {cause}"))
282 } else {
283 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
284 AppError::Refused(format!(
285 "apply aborted and restoration is incomplete; verify by hand: {}: {cause}",
286 paths.join(" ")
287 ))
288 }
289 };
290
291 for (destination, _) in &ordered {
292 let full = target.join(destination);
293 let previous = if full.is_file() {
294 Some(std::fs::read(&full).map_err(|source| {
295 AppError::Refused(format!("cannot back up {destination}: {source}"))
296 })?)
297 } else {
298 None
299 };
300 backups.insert((*destination).clone(), previous);
301 }
302
303 let write_all = || -> std::io::Result<()> {
304 for (destination, bytes) in &ordered {
305 if destination.as_str() != MANIFEST_PATH {
306 write_file(&target.join(destination), bytes)?;
307 }
308 }
309 for (destination, bytes) in &ordered {
310 if destination.as_str() == MANIFEST_PATH {
311 write_file(&target.join(destination), bytes)?;
312 }
313 }
314 Ok(())
315 };
316
317 if let Err(source) = write_all() {
318 return Err(abort(
319 rollback(&backups),
320 &format!("write failed: {source}"),
321 ));
322 }
323
324 match verifier::verify(target) {
325 Ok(report) if report.failures == 0 => Ok(()),
326 Ok(report) => {
327 let failures: Vec<&str> = report
328 .lines
329 .iter()
330 .filter(|line| line.starts_with("FAIL"))
331 .map(String::as_str)
332 .collect();
333 let cause = failures.join("; ");
334 Err(abort(rollback(&backups), &cause))
335 }
336 Err(source) => Err(abort(
337 rollback(&backups),
338 &format!("the written target could not be verified: {source}"),
339 )),
340 }
341}
342
343pub fn init(options: &InitOptions) -> Result<InitOutcome, AppError> {
352 let target = canonical_target(&options.target)?;
353 let forced_dry = !options.apply
354 && !options.dry_run
355 && target_has_content(&target)?
356 && !target.join(MANIFEST_PATH).is_file();
357 let dry = options.dry_run || forced_dry;
358
359 let state = compute_target_state(&target, options.profile)?;
360 let mut lines = state.lines.clone();
361
362 if dry {
363 if forced_dry {
364 lines.push(
365 "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
366 .to_string(),
367 );
368 }
369 lines.push("DRY RUN: no files written".to_string());
370 return Ok(InitOutcome {
371 lines,
372 applied: false,
373 });
374 }
375
376 apply(&target, &state)?;
377 Ok(InitOutcome {
378 lines,
379 applied: true,
380 })
381}