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}
44
45/// What an installation did.
46#[derive(Debug)]
47pub struct InitOutcome {
48    /// Every line to print: the proposed destinations, then any notices.
49    pub lines: Vec<String>,
50    /// Whether files were written.
51    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
91/// One field of whatever manifest the target already carries.
92///
93/// Read as free JSON rather than through [`Manifest::parse`]: a reinstall
94/// over a record of another schema version must still carry the operator's
95/// declared values forward, and a typed parse would refuse to read it.
96pub(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
114/// The plan zone to record: the flag, else the recorded value, else none.
115///
116/// An omitted flag never clears a declared value. `sdd upgrade` reinstalls
117/// with no flag at all, so "absent means the default" would erase the
118/// operator's declaration on every upgrade.
119///
120/// A recorded value this binary cannot decode refuses rather than defaults,
121/// for the same reason: writing the default over it would erase a
122/// declaration silently, which is the failure the preservation exists to
123/// prevent.
124///
125/// # Errors
126///
127/// [`AppError::ManifestInvalid`] when a value is recorded in a shape this
128/// binary does not understand.
129pub(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    // The same invariants the argument enforces. Carried forward unchecked,
146    // a hand-edited path fails the post-write verification instead, which
147    // rolls the whole target back and names no repair.
148    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
158/// The docs scratch to record: the flag, else the recorded value, else none.
159///
160/// The flag is two-level on purpose: absent keeps what is recorded, and
161/// `--docs-scratch none` clears it.
162///
163/// # Errors
164///
165/// [`AppError::ManifestInvalid`] when the recorded value is not a string.
166pub(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// sdd: permanent computing the target state is one ordered pass the installer replays
201#[allow(clippy::too_many_lines)]
202fn compute_target_state(target: &Utf8Path, options: &InitOptions) -> Result<TargetState, AppError> {
203    let profile = options.profile;
204    let declaration = profile.profile();
205    let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
206    let mut lines = Vec::new();
207    let mut managed_entries = Vec::new();
208    let mut adopted_entries = Vec::new();
209
210    for projection in declaration.managed {
211        let bytes = crate::embedded::asset(projection.source)
212            .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
213        let destination = Utf8PathBuf::from(projection.destination);
214        managed_entries.push(ManagedEntry {
215            source: projection.source.into(),
216            destination: destination.clone(),
217            sha256: Sha256::of(bytes),
218        });
219        lines.push(destination.to_string());
220        files.push((destination, bytes.to_vec()));
221    }
222
223    for projection in declaration.adopted {
224        let seed = crate::embedded::asset(projection.source)
225            .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
226        let destination = resolve_destination(projection.destination, declaration.docs_root);
227        let existing = target.join(&destination);
228        let bytes = if existing.is_file() {
229            std::fs::read(&existing)?
230        } else {
231            seed.to_vec()
232        };
233        adopted_entries.push(AdoptedEntry {
234            source: projection.source.into(),
235            destination: destination.clone(),
236            sha256: Sha256::of(&bytes),
237            baseline_sha256: Sha256::of(seed),
238        });
239        lines.push(destination.to_string());
240        files.push((destination, bytes));
241    }
242
243    let config_path = target.join(".pre-commit-config.yaml");
244    let host = if config_path.is_file() {
245        std::fs::read_to_string(&config_path)?
246    } else {
247        "repos:\n".to_string()
248    };
249    let (base, _) = crate::domain::marker::split_block(&host)?;
250    let indent = crate::domain::marker::splice_indent(&base)?;
251    let block = render_block(&RenderOptions {
252        docs_root: declaration.docs_root.to_string(),
253        indent,
254        ..RenderOptions::default()
255    });
256    let spliced = crate::domain::marker::splice(&base, &block)?;
257    let marker_hash = crate::domain::marker::block_hash(&spliced)
258        .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
259    lines.push(".pre-commit-config.yaml".to_string());
260    files.push((
261        Utf8PathBuf::from(".pre-commit-config.yaml"),
262        spliced.into_bytes(),
263    ));
264
265    let mut integration_blocks = vec![IntegrationBlock {
266        path: ".pre-commit-config.yaml".into(),
267        marker_hash,
268    }];
269
270    // The root AGENTS.md documentation block: the seam that makes SimpleEnglish
271    // arrive by default. A symlinked host is refused before it is read, so a
272    // link cannot redirect the read outside the target.
273    let agents_relative = Utf8Path::new("AGENTS.md");
274    if target.join(agents_relative).is_symlink() {
275        return Err(AppError::Refused(
276            "AGENTS.md is a symlink; refusing to write the documentation block through it"
277                .to_string(),
278        ));
279    }
280    let agents_host = if target.join(agents_relative).is_file() {
281        std::fs::read_to_string(target.join(agents_relative))?
282    } else {
283        String::new()
284    };
285    let agents_block =
286        crate::services::agents_render::render_block(&declaration.docs_root.to_string());
287    let agents = crate::domain::marker::place_agents_block(&agents_host, &agents_block)?;
288    let agents_hash = crate::domain::marker::block_hash_with(
289        &agents,
290        crate::domain::marker::AGENTS_BEGIN,
291        crate::domain::marker::AGENTS_END,
292    )
293    .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
294    // An old unmarked documentation section is preserved, never deleted; the
295    // note tells the operator to remove the duplicate by hand.
296    if agents_host.contains("## Documentation")
297        && crate::domain::marker::block_region_with(
298            &agents_host,
299            crate::domain::marker::AGENTS_BEGIN,
300            crate::domain::marker::AGENTS_END,
301        )
302        .is_none()
303    {
304        lines.push(
305            "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(),
306        );
307    }
308    lines.push("AGENTS.md".to_string());
309    files.push((agents_relative.to_path_buf(), agents.into_bytes()));
310    integration_blocks.push(IntegrationBlock {
311        path: "AGENTS.md".into(),
312        marker_hash: agents_hash,
313    });
314
315    let manifest = Manifest {
316        schema_version: SCHEMA_VERSION,
317        canon_version: CanonVersion::current(),
318        canon_source: CANON_SOURCE.to_string(),
319        profile,
320        docs_root: declaration.docs_root,
321        installed_at: installed_at(target),
322        plan_zone: resolved_plan_zone(target, options.plan_zone.as_ref())?,
323        docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
324        managed_files: managed_entries,
325        adopted_files: adopted_entries,
326        integration_blocks,
327    };
328    lines.push(MANIFEST_PATH.to_string());
329    files.push((
330        Utf8PathBuf::from(MANIFEST_PATH),
331        manifest.to_json().into_bytes(),
332    ));
333
334    Ok(TargetState { files, lines })
335}
336
337fn refusal_line(destination: &Utf8Path, refusal: &DestinationRefusal) -> String {
338    match refusal {
339        DestinationRefusal::SymlinkEscape => {
340            format!("destination escapes the target through a symlink: {destination}")
341        }
342        DestinationRefusal::FileBlocksDirectory(blocked) => {
343            format!("a file blocks a directory the install needs: {blocked}")
344        }
345        DestinationRefusal::NotARegularFile => {
346            format!("destination exists and is not a regular file: {destination}")
347        }
348    }
349}
350
351fn apply(target: &Utf8Path, state: &TargetState) -> Result<(), AppError> {
352    let mut ordered: Vec<&(Utf8PathBuf, Vec<u8>)> = state.files.iter().collect();
353    ordered.sort_by(|a, b| a.0.as_str().as_bytes().cmp(b.0.as_str().as_bytes()));
354
355    for (destination, _) in &ordered {
356        check_destination(target, destination)
357            .map_err(|refusal| AppError::Refused(refusal_line(destination, &refusal)))?;
358    }
359
360    let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
361    let rollback = |backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>| -> Vec<Utf8PathBuf> {
362        let mut unrestored = Vec::new();
363        for (destination, previous) in backups {
364            let full = target.join(destination);
365            let restored = previous.as_ref().map_or_else(
366                || std::fs::remove_file(&full).is_ok() || !full.exists(),
367                |bytes| write_file(&full, bytes).is_ok(),
368            );
369            if !restored {
370                unrestored.push(destination.clone());
371            }
372        }
373        unrestored
374    };
375    // The cause travels with the refusal: the caller has already lost the
376    // written tree by the time it reads this, so a bare "aborted" leaves
377    // nothing to act on.
378    let abort = |unrestored: Vec<Utf8PathBuf>, cause: &str| {
379        if unrestored.is_empty() {
380            AppError::Refused(format!("apply aborted; the target was restored: {cause}"))
381        } else {
382            let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
383            AppError::Refused(format!(
384                "apply aborted and restoration is incomplete; verify by hand: {}: {cause}",
385                paths.join(" ")
386            ))
387        }
388    };
389
390    for (destination, _) in &ordered {
391        let full = target.join(destination);
392        let previous = if full.is_file() {
393            Some(std::fs::read(&full).map_err(|source| {
394                AppError::Refused(format!("cannot back up {destination}: {source}"))
395            })?)
396        } else {
397            None
398        };
399        backups.insert((*destination).clone(), previous);
400    }
401
402    let write_all = || -> std::io::Result<()> {
403        for (destination, bytes) in &ordered {
404            if destination.as_str() != MANIFEST_PATH {
405                write_file(&target.join(destination), bytes)?;
406            }
407        }
408        for (destination, bytes) in &ordered {
409            if destination.as_str() == MANIFEST_PATH {
410                write_file(&target.join(destination), bytes)?;
411            }
412        }
413        Ok(())
414    };
415
416    if let Err(source) = write_all() {
417        return Err(abort(
418            rollback(&backups),
419            &format!("write failed: {source}"),
420        ));
421    }
422
423    match verifier::verify(target) {
424        Ok(report) if report.failures == 0 => Ok(()),
425        Ok(report) => {
426            let failures: Vec<&str> = report
427                .lines
428                .iter()
429                .filter(|line| line.starts_with("FAIL"))
430                .map(String::as_str)
431                .collect();
432            let cause = failures.join("; ");
433            Err(abort(rollback(&backups), &cause))
434        }
435        Err(source) => Err(abort(
436            rollback(&backups),
437            &format!("the written target could not be verified: {source}"),
438        )),
439    }
440}
441
442/// Install or reinstall an instance.
443///
444/// # Errors
445///
446/// [`AppError::Usage`] for a target the arguments cannot mean,
447/// [`AppError::Marker`] for a configuration whose markers cannot be trusted,
448/// and [`AppError::Refused`] when the apply could not complete — the target
449/// is restored before that returns.
450pub fn init(options: &InitOptions) -> Result<InitOutcome, AppError> {
451    let target = canonical_target(&options.target)?;
452    let forced_dry = !options.apply
453        && !options.dry_run
454        && target_has_content(&target)?
455        && !target.join(MANIFEST_PATH).is_file();
456    let dry = options.dry_run || forced_dry;
457
458    let state = compute_target_state(&target, options)?;
459    let mut lines = state.lines.clone();
460
461    if dry {
462        if forced_dry {
463            lines.push(
464                "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
465                    .to_string(),
466            );
467        }
468        lines.push("DRY RUN: no files written".to_string());
469        return Ok(InitOutcome {
470            lines,
471            applied: false,
472        });
473    }
474
475    apply(&target, &state)?;
476    Ok(InitOutcome {
477        lines,
478        applied: true,
479    })
480}