Skip to main content

spec_driven_docs/services/
installer.rs

1//! Instance installation: project the embedded payload into a target.
2//!
3//! The chezmoi vocabulary applies: the embedded payload and profile are the
4//! source state, the computed projection is the target state, the repository
5//! on disk is the destination state, and the manifest — written last — is
6//! the persistent entry state. The whole target state is computed before a
7//! byte lands; a non-empty target previews by default; every destination is
8//! guarded; and any failure mid-apply rolls the target back. What the
9//! payload contains is `embedded`'s and the profiles' business.
10
11use 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/// What an installation was asked to do.
28#[derive(Debug, Clone)]
29pub struct InitOptions {
30    /// The absolute target repository.
31    pub target: Utf8PathBuf,
32    /// The profile to project.
33    pub profile: ProfileId,
34    /// Write even into a non-empty target with no instance.
35    pub apply: bool,
36    /// Preview only, regardless of the target's state.
37    pub dry_run: bool,
38    /// The plan zone to record; `None` keeps whatever is recorded.
39    pub plan_zone: Option<PlanZone>,
40    /// The docs scratch to record. `None` keeps whatever is recorded, and
41    /// `Some(None)` clears it.
42    pub docs_scratch: Option<Option<Utf8PathBuf>>,
43    /// Paths to record under `reserved:` in the instance's declaration. An
44    /// empty list keeps whatever is recorded.
45    pub reserve: Vec<String>,
46}
47
48/// What an installation did.
49#[derive(Debug)]
50pub struct InitOutcome {
51    /// Every line to print: the proposed destinations, then any notices.
52    pub lines: Vec<String>,
53    /// Whether files were written.
54    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
94/// One field of whatever manifest the target already carries.
95///
96/// Read as free JSON rather than through [`Manifest::parse`]: a reinstall
97/// over a record of another schema version must still carry the operator's
98/// declared values forward, and a typed parse would refuse to read it.
99pub(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
117/// The plan zone to record: the flag, else the recorded value, else none.
118///
119/// An omitted flag never clears a declared value. `sdd upgrade` reinstalls
120/// with no flag at all, so "absent means the default" would erase the
121/// operator's declaration on every upgrade.
122///
123/// A recorded value this binary cannot decode refuses rather than defaults,
124/// for the same reason: writing the default over it would erase a
125/// declaration silently, which is the failure the preservation exists to
126/// prevent.
127///
128/// # Errors
129///
130/// [`AppError::ManifestInvalid`] when a value is recorded in a shape this
131/// binary does not understand.
132pub(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    // The same invariants the argument enforces. Carried forward unchecked,
149    // a hand-edited path fails the post-write verification instead, which
150    // rolls the whole target back and names no repair.
151    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
161/// The docs scratch to record: the flag, else the recorded value, else none.
162///
163/// The flag is two-level on purpose: absent keeps what is recorded, and
164/// `--docs-scratch none` clears it.
165///
166/// # Errors
167///
168/// [`AppError::ManifestInvalid`] when the recorded value is not a string.
169pub(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        // `--reserve` records into the declaration, keeping its comments and
239        // whatever the project already wrote there.
240        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    // Render from the declaration this install is writing, not from the one
266    // on disk. With `--reserve` they differ, and a block rendered from the
267    // old one would disagree with the file the same install lands.
268    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    // The root AGENTS.md documentation block routes authors to the context they
297    // load before editing. A symlinked host is refused before it is read, so a
298    // link cannot redirect the read outside the target.
299    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    // An old unmarked documentation section is preserved, never deleted; the
321    // note tells the operator to remove the duplicate by hand.
322    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    // The cause travels with the refusal: the caller has already lost the
402    // written tree by the time it reads this, so a bare "aborted" leaves
403    // nothing to act on.
404    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
468/// Install or reinstall an instance.
469///
470/// # Errors
471///
472/// [`AppError::Usage`] for a target the arguments cannot mean,
473/// [`AppError::Marker`] for a configuration whose markers cannot be trusted,
474/// and [`AppError::Refused`] when the apply could not complete — the target
475/// is restored before that returns.
476pub 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}