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