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    /// The writing-style selection to record in the declaration. `None`
47    /// keeps whatever is recorded.
48    pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
49}
50
51/// What an installation did.
52#[derive(Debug)]
53pub struct InitOutcome {
54    /// Every line to print: the proposed destinations, then any notices.
55    pub lines: Vec<String>,
56    /// Whether files were written.
57    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
97/// One field of whatever manifest the target already carries.
98///
99/// Read as free JSON rather than through [`Manifest::parse`]: a reinstall
100/// over a record of another schema version must still carry the operator's
101/// declared values forward, and a typed parse would refuse to read it.
102pub(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
120/// The plan zone to record: the flag, else the recorded value, else none.
121///
122/// An omitted flag never clears a declared value. `sdd upgrade` reinstalls
123/// with no flag at all, so "absent means the default" would erase the
124/// operator's declaration on every upgrade.
125///
126/// A recorded value this binary cannot decode refuses rather than defaults,
127/// for the same reason: writing the default over it would erase a
128/// declaration silently, which is the failure the preservation exists to
129/// prevent.
130///
131/// # Errors
132///
133/// [`AppError::ManifestInvalid`] when a value is recorded in a shape this
134/// binary does not understand.
135pub(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    // The same invariants the argument enforces. Carried forward unchecked,
152    // a hand-edited path fails the post-write verification instead, which
153    // rolls the whole target back and names no repair.
154    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
164/// The docs scratch to record: the flag, else the recorded value, else none.
165///
166/// The flag is two-level on purpose: absent keeps what is recorded, and
167/// `--docs-scratch none` clears it.
168///
169/// # Errors
170///
171/// [`AppError::ManifestInvalid`] when the recorded value is not a string.
172pub(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    // What the target already records as adopted. A destination that holds
232    // project content and is recorded nowhere is preserved and noted: the
233    // seed does not land, and the project should know the specification it
234    // would have received.
235    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        // `--reserve` and `--writing-style` record into the declaration,
262        // keeping its comments and whatever the project already wrote there.
263        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    // Render from the declaration this install is writing, not from the one
294    // on disk. With `--reserve` they differ, and a block rendered from the
295    // old one would disagree with the file the same install lands.
296    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    // The root AGENTS.md documentation block routes authors to the context they
326    // load before editing. A symlinked host is refused before it is read, so a
327    // link cannot redirect the read outside the target.
328    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    // An old unmarked documentation section is preserved, never deleted; the
352    // note tells the operator to remove the duplicate by hand.
353    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    // The cause travels with the refusal: the caller has already lost the
433    // written tree by the time it reads this, so a bare "aborted" leaves
434    // nothing to act on.
435    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
499/// Install or reinstall an instance.
500///
501/// # Errors
502///
503/// [`AppError::Usage`] for a target the arguments cannot mean,
504/// [`AppError::Marker`] for a configuration whose markers cannot be trusted,
505/// and [`AppError::Refused`] when the apply could not complete — the target
506/// is restored before that returns.
507pub 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}