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