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::{
17 CANON_SOURCE, MANIFEST_PATH, Manifest, PlanZone, SCHEMA_VERSION, validate_docs_scratch_path,
18 validate_plan_zone_path,
19};
20use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
21use crate::domain::profile::{ProfileId, resolve_destination};
22use crate::domain::version::CanonVersion;
23use crate::error::AppError;
24use crate::services::hooks_render::{RenderOptions, render_block};
25use crate::services::verifier;
26
27#[derive(Debug, Clone)]
29pub struct InitOptions {
30 pub target: Utf8PathBuf,
32 pub profile: ProfileId,
34 pub apply: bool,
36 pub dry_run: bool,
38 pub plan_zone: Option<PlanZone>,
40 pub docs_scratch: Option<Option<Utf8PathBuf>>,
43 pub reserve: Vec<String>,
46}
47
48#[derive(Debug)]
50pub struct InitOutcome {
51 pub lines: Vec<String>,
53 pub applied: bool,
55}
56
57fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
58 if !target.is_absolute() {
59 return Err(AppError::Usage("target must be absolute".to_string()));
60 }
61 if !target.is_dir() {
62 return Err(AppError::Usage(format!("unresolved target: {target}")));
63 }
64 let canonical = std::fs::canonicalize(target)?;
65 let canonical = Utf8PathBuf::from_path_buf(canonical)
66 .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
67 if canonical.as_str().chars().all(|c| c == '/') {
68 return Err(AppError::Usage("refusing root target".to_string()));
69 }
70 let mut ancestor = Some(canonical.as_path());
71 while let Some(dir) = ancestor {
72 if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
73 && cargo.contains("name = \"spec-driven-docs\"")
74 {
75 return Err(AppError::Usage(
76 "target is inside the canon checkout".to_string(),
77 ));
78 }
79 ancestor = dir.parent();
80 }
81 Ok(canonical)
82}
83
84fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
85 for entry in target.read_dir_utf8()? {
86 let entry = entry?;
87 if entry.file_name() != ".git" {
88 return Ok(true);
89 }
90 }
91 Ok(false)
92}
93
94pub(crate) fn recorded_field(target: &Utf8Path, key: &str) -> Option<serde_json::Value> {
100 std::fs::read_to_string(target.join(MANIFEST_PATH))
101 .ok()
102 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
103 .and_then(|value| value.get(key).cloned())
104 .filter(|value| !value.is_null())
105}
106
107fn installed_at(target: &Utf8Path) -> String {
108 recorded_field(target, "installed_at")
109 .and_then(|value| value.as_str().map(String::from))
110 .unwrap_or_else(|| {
111 jiff::Timestamp::now()
112 .strftime("%Y-%m-%dT%H:%M:%SZ")
113 .to_string()
114 })
115}
116
117pub(crate) fn resolved_plan_zone(
133 target: &Utf8Path,
134 flag: Option<&PlanZone>,
135) -> Result<PlanZone, AppError> {
136 if let Some(zone) = flag {
137 return Ok(zone.clone());
138 }
139 let Some(recorded) = recorded_field(target, "plan_zone") else {
140 return Ok(PlanZone::default());
141 };
142 let zone: PlanZone = serde_json::from_value(recorded).map_err(|source| {
143 AppError::ManifestInvalid(format!(
144 "the recorded plan_zone is in a shape this sdd does not read ({source}); \
145 upgrade sdd, or re-declare it with --plan-zone"
146 ))
147 })?;
148 if let Some(path) = zone.path()
152 && let Err(error) = validate_plan_zone_path(path)
153 {
154 return Err(AppError::ManifestInvalid(format!(
155 "the recorded plan_zone is not usable ({error}); re-declare it with --plan-zone"
156 )));
157 }
158 Ok(zone)
159}
160
161pub(crate) fn resolved_docs_scratch(
170 target: &Utf8Path,
171 flag: Option<&Option<Utf8PathBuf>>,
172) -> Result<Option<Utf8PathBuf>, AppError> {
173 if let Some(declared) = flag {
174 return Ok(declared.clone());
175 }
176 let Some(recorded) = recorded_field(target, "docs_scratch") else {
177 return Ok(None);
178 };
179 let path = recorded
180 .as_str()
181 .filter(|path| !path.is_empty())
182 .map(Utf8PathBuf::from)
183 .ok_or_else(|| {
184 AppError::ManifestInvalid(format!(
185 "the recorded docs_scratch is not a path ({recorded}); \
186 re-declare it with --docs-scratch"
187 ))
188 })?;
189 if let Err(error) = validate_docs_scratch_path(&path) {
190 return Err(AppError::ManifestInvalid(format!(
191 "the recorded docs_scratch is not usable ({error}); \
192 re-declare it with --docs-scratch"
193 )));
194 }
195 Ok(Some(path))
196}
197
198struct TargetState {
199 files: Vec<(Utf8PathBuf, Vec<u8>)>,
200 lines: Vec<String>,
201}
202
203#[allow(
204 clippy::too_many_lines,
205 reason = "computing the target state is one ordered pass the installer replays"
206)]
207fn compute_target_state(target: &Utf8Path, options: &InitOptions) -> Result<TargetState, AppError> {
208 let profile = options.profile;
209 let declaration = profile.profile();
210 let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
211 let mut lines = Vec::new();
212 let mut managed_entries = Vec::new();
213 let mut adopted_entries = Vec::new();
214
215 for projection in declaration.managed {
216 let bytes = crate::embedded::asset(projection.source)
217 .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
218 let destination = Utf8PathBuf::from(projection.destination);
219 managed_entries.push(ManagedEntry {
220 source: projection.source.into(),
221 destination: destination.clone(),
222 sha256: Sha256::of(bytes),
223 });
224 lines.push(destination.to_string());
225 files.push((destination, bytes.to_vec()));
226 }
227
228 for projection in declaration.adopted {
229 let seed = crate::embedded::asset(projection.source)
230 .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
231 let destination = resolve_destination(projection.destination, declaration.docs_root);
232 let existing = target.join(&destination);
233 let mut bytes = if existing.is_file() {
234 std::fs::read(&existing)?
235 } else {
236 seed.to_vec()
237 };
238 if destination == crate::domain::instance_config::CONFIG_PATH && !options.reserve.is_empty()
241 {
242 if let Ok(text) = std::str::from_utf8(&bytes) {
243 bytes = crate::domain::instance_config::with_reserved(text, &options.reserve)
244 .into_bytes();
245 }
246 }
247 adopted_entries.push(AdoptedEntry {
248 source: projection.source.into(),
249 destination: destination.clone(),
250 sha256: Sha256::of(&bytes),
251 baseline_sha256: Sha256::of(seed),
252 });
253 lines.push(destination.to_string());
254 files.push((destination, bytes));
255 }
256
257 let config_path = target.join(".pre-commit-config.yaml");
258 let host = if config_path.is_file() {
259 std::fs::read_to_string(&config_path)?
260 } else {
261 "repos:\n".to_string()
262 };
263 let (base, _) = crate::domain::marker::split_block(&host)?;
264 let indent = crate::domain::marker::splice_indent(&base)?;
265 let declared = files
269 .iter()
270 .find(|(destination, _)| destination == crate::domain::instance_config::CONFIG_PATH)
271 .and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
272 .map(crate::domain::instance_config::InstanceConfig::parse)
273 .transpose()
274 .map_err(|error| anyhow::anyhow!("{error}"))?
275 .unwrap_or_default();
276 let block = render_block(&RenderOptions {
277 docs_root: declaration.docs_root.to_string(),
278 indent,
279 declaration: declared,
280 ..RenderOptions::default()
281 });
282 let spliced = crate::domain::marker::splice(&base, &block)?;
283 let marker_hash = crate::domain::marker::block_hash(&spliced)
284 .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
285 lines.push(".pre-commit-config.yaml".to_string());
286 files.push((
287 Utf8PathBuf::from(".pre-commit-config.yaml"),
288 spliced.into_bytes(),
289 ));
290
291 let mut integration_blocks = vec![IntegrationBlock {
292 path: ".pre-commit-config.yaml".into(),
293 marker_hash,
294 }];
295
296 let agents_relative = Utf8Path::new("AGENTS.md");
300 if target.join(agents_relative).is_symlink() {
301 return Err(AppError::Refused(
302 "AGENTS.md is a symlink; refusing to write the documentation block through it"
303 .to_string(),
304 ));
305 }
306 let agents_host = if target.join(agents_relative).is_file() {
307 std::fs::read_to_string(target.join(agents_relative))?
308 } else {
309 String::new()
310 };
311 let agents_block =
312 crate::services::agents_render::render_block(&declaration.docs_root.to_string());
313 let agents = crate::domain::marker::place_agents_block(&agents_host, &agents_block)?;
314 let agents_hash = crate::domain::marker::block_hash_with(
315 &agents,
316 crate::domain::marker::AGENTS_BEGIN,
317 crate::domain::marker::AGENTS_END,
318 )
319 .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
320 if agents_host.contains("## Documentation")
323 && crate::domain::marker::block_region_with(
324 &agents_host,
325 crate::domain::marker::AGENTS_BEGIN,
326 crate::domain::marker::AGENTS_END,
327 )
328 .is_none()
329 {
330 lines.push(
331 "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(),
332 );
333 }
334 lines.push("AGENTS.md".to_string());
335 files.push((agents_relative.to_path_buf(), agents.into_bytes()));
336 integration_blocks.push(IntegrationBlock {
337 path: "AGENTS.md".into(),
338 marker_hash: agents_hash,
339 });
340
341 let manifest = Manifest {
342 schema_version: SCHEMA_VERSION,
343 canon_version: CanonVersion::current(),
344 canon_source: CANON_SOURCE.to_string(),
345 profile,
346 docs_root: declaration.docs_root,
347 installed_at: installed_at(target),
348 plan_zone: resolved_plan_zone(target, options.plan_zone.as_ref())?,
349 docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
350 managed_files: managed_entries,
351 adopted_files: adopted_entries,
352 integration_blocks,
353 };
354 lines.push(MANIFEST_PATH.to_string());
355 files.push((
356 Utf8PathBuf::from(MANIFEST_PATH),
357 manifest.to_json().into_bytes(),
358 ));
359
360 Ok(TargetState { files, lines })
361}
362
363fn refusal_line(destination: &Utf8Path, refusal: &DestinationRefusal) -> String {
364 match refusal {
365 DestinationRefusal::SymlinkEscape => {
366 format!("destination escapes the target through a symlink: {destination}")
367 }
368 DestinationRefusal::FileBlocksDirectory(blocked) => {
369 format!("a file blocks a directory the install needs: {blocked}")
370 }
371 DestinationRefusal::NotARegularFile => {
372 format!("destination exists and is not a regular file: {destination}")
373 }
374 }
375}
376
377fn apply(target: &Utf8Path, state: &TargetState) -> Result<(), AppError> {
378 let mut ordered: Vec<&(Utf8PathBuf, Vec<u8>)> = state.files.iter().collect();
379 ordered.sort_by(|a, b| a.0.as_str().as_bytes().cmp(b.0.as_str().as_bytes()));
380
381 for (destination, _) in &ordered {
382 check_destination(target, destination)
383 .map_err(|refusal| AppError::Refused(refusal_line(destination, &refusal)))?;
384 }
385
386 let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
387 let rollback = |backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>| -> Vec<Utf8PathBuf> {
388 let mut unrestored = Vec::new();
389 for (destination, previous) in backups {
390 let full = target.join(destination);
391 let restored = previous.as_ref().map_or_else(
392 || std::fs::remove_file(&full).is_ok() || !full.exists(),
393 |bytes| write_file(&full, bytes).is_ok(),
394 );
395 if !restored {
396 unrestored.push(destination.clone());
397 }
398 }
399 unrestored
400 };
401 let abort = |unrestored: Vec<Utf8PathBuf>, cause: &str| {
405 if unrestored.is_empty() {
406 AppError::Refused(format!("apply aborted; the target was restored: {cause}"))
407 } else {
408 let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
409 AppError::Refused(format!(
410 "apply aborted and restoration is incomplete; verify by hand: {}: {cause}",
411 paths.join(" ")
412 ))
413 }
414 };
415
416 for (destination, _) in &ordered {
417 let full = target.join(destination);
418 let previous = if full.is_file() {
419 Some(std::fs::read(&full).map_err(|source| {
420 AppError::Refused(format!("cannot back up {destination}: {source}"))
421 })?)
422 } else {
423 None
424 };
425 backups.insert((*destination).clone(), previous);
426 }
427
428 let write_all = || -> std::io::Result<()> {
429 for (destination, bytes) in &ordered {
430 if destination.as_str() != MANIFEST_PATH {
431 write_file(&target.join(destination), bytes)?;
432 }
433 }
434 for (destination, bytes) in &ordered {
435 if destination.as_str() == MANIFEST_PATH {
436 write_file(&target.join(destination), bytes)?;
437 }
438 }
439 Ok(())
440 };
441
442 if let Err(source) = write_all() {
443 return Err(abort(
444 rollback(&backups),
445 &format!("write failed: {source}"),
446 ));
447 }
448
449 match verifier::verify(target) {
450 Ok(report) if report.failures == 0 => Ok(()),
451 Ok(report) => {
452 let failures: Vec<&str> = report
453 .lines
454 .iter()
455 .filter(|line| line.starts_with("FAIL"))
456 .map(String::as_str)
457 .collect();
458 let cause = failures.join("; ");
459 Err(abort(rollback(&backups), &cause))
460 }
461 Err(source) => Err(abort(
462 rollback(&backups),
463 &format!("the written target could not be verified: {source}"),
464 )),
465 }
466}
467
468pub fn init(options: &InitOptions) -> Result<InitOutcome, AppError> {
477 let target = canonical_target(&options.target)?;
478 let forced_dry = !options.apply
479 && !options.dry_run
480 && target_has_content(&target)?
481 && !target.join(MANIFEST_PATH).is_file();
482 let dry = options.dry_run || forced_dry;
483
484 let state = compute_target_state(&target, options)?;
485 let mut lines = state.lines.clone();
486
487 if dry {
488 if forced_dry {
489 lines.push(
490 "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
491 .to_string(),
492 );
493 }
494 lines.push("DRY RUN: no files written".to_string());
495 return Ok(InitOutcome {
496 lines,
497 applied: false,
498 });
499 }
500
501 apply(&target, &state)?;
502 Ok(InitOutcome {
503 lines,
504 applied: true,
505 })
506}