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 camino::{Utf8Path, Utf8PathBuf};
12
13use crate::domain::manifest::{
14    CANON_SOURCE, MANIFEST_PATH, Manifest, PlanZone, SCHEMA_VERSION, validate_docs_scratch_path,
15    validate_plan_zone_path,
16};
17use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
18use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
19use crate::domain::profile::{ProfileId, resolve_destination};
20use crate::domain::version::CanonVersion;
21use crate::error::AppError;
22use crate::release::ReleaseBundle;
23use crate::services::hooks_render::{RenderOptions, render_block};
24
25/// What an installation was asked to do.
26#[derive(Debug, Clone)]
27pub struct InitOptions {
28    /// The absolute target repository.
29    pub target: Utf8PathBuf,
30    /// The profile to project.
31    pub profile: ProfileId,
32    /// Write even into a non-empty target with no instance.
33    pub apply: bool,
34    /// Preview only, regardless of the target's state.
35    pub dry_run: bool,
36    /// The plan zone to record; `None` keeps whatever is recorded.
37    pub plan_zone: Option<PlanZone>,
38    /// The docs scratch to record. `None` keeps whatever is recorded, and
39    /// `Some(None)` clears it.
40    pub docs_scratch: Option<Option<Utf8PathBuf>>,
41    /// Paths to record under `reserved:` in the instance's declaration. An
42    /// empty list keeps whatever is recorded.
43    pub reserve: Vec<String>,
44    /// The writing-style selection to record in the declaration. `None`
45    /// keeps whatever is recorded.
46    pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
47}
48
49/// What an installation did.
50#[derive(Debug)]
51pub struct InitOutcome {
52    /// Every line to print: the proposed destinations, then any notices.
53    pub lines: Vec<String>,
54    /// Whether files were written.
55    pub applied: bool,
56    /// Every destination the landing took back, relative to the target.
57    pub removed: Vec<String>,
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
201/// Every byte one landing would put in a target, and what it would say.
202///
203/// The planner takes this rather than deriving it a second time: what a
204/// release lands into a target is one computation, and two of them would
205/// be two places for one rule to drift.
206#[derive(Debug, Clone)]
207pub struct TargetState {
208    /// Each destination and the bytes that would go there.
209    pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
210    /// What the operator would be told.
211    pub lines: Vec<String>,
212}
213
214#[allow(
215    clippy::too_many_lines,
216    reason = "computing the target state is one ordered pass the installer replays"
217)]
218/// What one landing would put in a target.
219///
220/// # Errors
221///
222/// [`AppError::Refused`] when the release declares no such profile or a
223/// marked region cannot be read, and I/O errors reading the target.
224pub fn compute_target_state(
225    target: &Utf8Path,
226    options: &InitOptions,
227    bundle: &dyn ReleaseBundle,
228) -> Result<TargetState, AppError> {
229    let profile = options.profile;
230    // The release the bundle is, not the release the engine is. A plan
231    // toward an older version lands that version's bytes, so recording
232    // this binary's version would leave the target claiming a release it
233    // does not hold, and every later classification would read the lie.
234    let landed: CanonVersion = bundle
235        .manifest()?
236        .version
237        .to_string()
238        .parse()
239        .map_err(|_| AppError::Refused("the release is not a version triple".to_string()))?;
240    let released = bundle.declaration()?;
241    let declaration = released.profile(profile).ok_or_else(|| {
242        AppError::Refused(format!(
243            "the release declares no {profile} profile, so it cannot land one"
244        ))
245    })?;
246    let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
247    let mut lines = Vec::new();
248    let mut managed_entries = Vec::new();
249    let mut adopted_entries = Vec::new();
250
251    for projection in declaration.managed {
252        let bytes = bundle.artifact(&projection.source)?;
253        let destination = Utf8PathBuf::from(&projection.destination);
254        managed_entries.push(ManagedEntry {
255            source: projection.source.clone().into(),
256            destination: destination.clone(),
257            sha256: Sha256::of(&bytes),
258        });
259        lines.push(destination.to_string());
260        files.push((destination, bytes));
261    }
262
263    // What the target already records as adopted. A destination that holds
264    // project content and is recorded nowhere is preserved and noted: the
265    // seed does not land, and the project should know the specification it
266    // would have received.
267    let recorded_adopted: Vec<String> = recorded_field(target, "adopted_files")
268        .and_then(|value| {
269            value.as_array().map(|entries| {
270                entries
271                    .iter()
272                    .filter_map(|entry| entry.get("destination")?.as_str().map(String::from))
273                    .collect()
274            })
275        })
276        .unwrap_or_default();
277    for projection in declaration.adopted {
278        let seed = bundle.artifact(&projection.source)?;
279        let destination = resolve_destination(&projection.destination, declaration.docs_root);
280        let existing = target.join(&destination);
281        let mut bytes = if existing.is_file() {
282            let held = std::fs::read(&existing)?;
283            if held != seed && !recorded_adopted.iter().any(|d| d == destination.as_str()) {
284                lines.push(format!(
285                    "note: {destination} already exists and is kept; the seed was not written, so read it with 'sdd spec' and reconcile by hand"
286                ));
287            }
288            held
289        } else {
290            seed.clone()
291        };
292        // `--reserve` and `--writing-style` record into the declaration,
293        // keeping its comments and whatever the project already wrote there.
294        if destination == crate::domain::instance_config::CONFIG_PATH
295            && let Ok(text) = std::str::from_utf8(&bytes)
296        {
297            let mut text = text.to_string();
298            if !options.reserve.is_empty() {
299                text = crate::domain::instance_config::with_reserved(&text, &options.reserve);
300            }
301            if let Some(selection) = &options.writing_style {
302                text = crate::domain::instance_config::with_writing_style(&text, selection);
303            }
304            bytes = text.into_bytes();
305        }
306        adopted_entries.push(AdoptedEntry {
307            source: projection.source.clone().into(),
308            destination: destination.clone(),
309            sha256: Sha256::of(&bytes),
310            baseline_sha256: Sha256::of(&seed),
311        });
312        lines.push(destination.to_string());
313        files.push((destination, bytes));
314    }
315
316    let config_path = target.join(HOOKS_CONFIG_PATH);
317    let host = if config_path.is_file() {
318        std::fs::read_to_string(&config_path)?
319    } else {
320        "repos:\n".to_string()
321    };
322    let (base, _) = crate::domain::marker::split_block(&host)?;
323    let indent = crate::domain::marker::splice_indent(&base)?;
324    // Render from the declaration this install is writing, not from the one
325    // on disk. With `--reserve` they differ, and a block rendered from the
326    // old one would disagree with the file the same install lands.
327    let declared = files
328        .iter()
329        .find(|(destination, _)| destination == crate::domain::instance_config::CONFIG_PATH)
330        .and_then(|(_, bytes)| std::str::from_utf8(bytes).ok())
331        .map(crate::domain::instance_config::InstanceConfig::parse)
332        .transpose()
333        .map_err(|error| anyhow::anyhow!("{error}"))?
334        .unwrap_or_default();
335    let writing_style = declared.writing_style.clone();
336    let block = render_block(&RenderOptions {
337        docs_root: declaration.docs_root.to_string(),
338        indent,
339        declaration: declared,
340        ..RenderOptions::default()
341    });
342    let spliced = crate::domain::marker::splice(&base, &block)?;
343    let marker_hash = crate::domain::marker::block_hash(&spliced)
344        .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
345    lines.push(HOOKS_CONFIG_PATH.to_string());
346    files.push((Utf8PathBuf::from(HOOKS_CONFIG_PATH), spliced.into_bytes()));
347
348    let mut integration_blocks = vec![IntegrationBlock {
349        path: HOOKS_CONFIG_PATH.into(),
350        marker_hash,
351    }];
352
353    // The root AGENTS.md documentation block routes authors to the context they
354    // load before editing. A symlinked host is refused before it is read, so a
355    // link cannot redirect the read outside the target.
356    let agents_relative = Utf8Path::new(AGENTS_DIGEST_PATH);
357    if target.join(agents_relative).is_symlink() {
358        return Err(AppError::Refused(
359            "AGENTS.md is a symlink; refusing to write the documentation block through it"
360                .to_string(),
361        ));
362    }
363    let agents_host = if target.join(agents_relative).is_file() {
364        std::fs::read_to_string(target.join(agents_relative))?
365    } else {
366        String::new()
367    };
368    let agents_block = crate::services::agents_render::render_block(
369        &declaration.docs_root.to_string(),
370        &writing_style,
371    );
372    let agents = crate::domain::marker::place_agents_block(&agents_host, &agents_block)?;
373    let agents_hash = crate::domain::marker::block_hash_with(
374        &agents,
375        crate::domain::marker::AGENTS_BEGIN,
376        crate::domain::marker::AGENTS_END,
377    )
378    .ok_or_else(|| anyhow::anyhow!("the rendered AGENTS.md block lost its markers"))?;
379    // An old unmarked documentation section is preserved, never deleted; the
380    // note tells the operator to remove the duplicate by hand.
381    if agents_host.contains("## Documentation")
382        && crate::domain::marker::block_region_with(
383            &agents_host,
384            crate::domain::marker::AGENTS_BEGIN,
385            crate::domain::marker::AGENTS_END,
386        )
387        .is_none()
388    {
389        lines.push(
390            "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(),
391        );
392    }
393    lines.push(AGENTS_DIGEST_PATH.to_string());
394    files.push((agents_relative.to_path_buf(), agents.into_bytes()));
395    integration_blocks.push(IntegrationBlock {
396        path: AGENTS_DIGEST_PATH.into(),
397        marker_hash: agents_hash,
398    });
399
400    let manifest = Manifest {
401        schema_version: SCHEMA_VERSION,
402        canon_version: landed,
403        canon_source: CANON_SOURCE.to_string(),
404        profile,
405        docs_root: declaration.docs_root,
406        installed_at: installed_at(target),
407        plan_zone: resolved_plan_zone(target, options.plan_zone.as_ref())?,
408        docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
409        managed_files: managed_entries,
410        adopted_files: adopted_entries,
411        integration_blocks,
412    };
413    lines.push(MANIFEST_PATH.to_string());
414    files.push((
415        Utf8PathBuf::from(MANIFEST_PATH),
416        manifest.to_json().into_bytes(),
417    ));
418
419    Ok(TargetState { files, lines })
420}
421
422/// Install or reinstall an instance.
423///
424/// # Errors
425///
426/// [`AppError::Usage`] for a target the arguments cannot mean,
427/// [`AppError::Marker`] for a configuration whose markers cannot be trusted,
428/// and [`AppError::Refused`] when the apply could not complete — the target
429/// is restored before that returns.
430pub fn init(
431    options: &InitOptions,
432    bundle: &dyn ReleaseBundle,
433    intent: crate::plan::classify::Intent,
434) -> Result<InitOutcome, AppError> {
435    init_with(
436        &crate::plan::decision::Selections::new(),
437        options,
438        bundle,
439        intent,
440    )
441}
442
443/// Install or reinstall an instance, carrying answers the caller collected.
444///
445/// # Errors
446///
447/// As [`init`], plus whatever the plan refuses when a decision it raises
448/// is unanswered.
449pub fn init_with(
450    answered: &crate::plan::decision::Selections,
451    options: &InitOptions,
452    bundle: &dyn ReleaseBundle,
453    intent: crate::plan::classify::Intent,
454) -> Result<InitOutcome, AppError> {
455    let target = canonical_target(&options.target)?;
456    // The target is known-good before it is classified, so an argument
457    // this verb cannot mean is a usage answer rather than a walk of
458    // whatever the argument happened to name.
459    crate::commands::front::serves(intent, &target)?;
460    let forced_dry = !options.apply
461        && !options.dry_run
462        && target_has_content(&target)?
463        && !target.join(MANIFEST_PATH).is_file();
464    let dry = options.dry_run || forced_dry;
465
466    let state = compute_target_state(&target, options, bundle)?;
467    let mut lines = state.lines;
468
469    let landing = crate::plan::session::Landing {
470        target: &target,
471        release: crate::plan::session::ReleaseRef::of(bundle)?,
472        offline: true,
473        selections: answered.clone(),
474        carried: profile_only(options.profile),
475        reserve: options.reserve.clone(),
476        declared: Some(options.clone()),
477    };
478
479    if dry {
480        if forced_dry {
481            lines.push(
482                "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
483                    .to_string(),
484            );
485        }
486        // The preview is the plan. A destination list alone cannot say
487        // that a precondition blocks the run or that a release in the
488        // interval asks something of a person.
489        lines.extend(crate::plan::session::preview_lines(
490            &crate::plan::session::preview(&landing)?,
491        ));
492        lines.push("DRY RUN: no files written".to_string());
493        return Ok(InitOutcome {
494            lines,
495            applied: false,
496            removed: Vec::new(),
497        });
498    }
499
500    // Every write into a target comes from an operation in one plan, so
501    // this verb reaches the engine rather than writing what it computed.
502    // The state above is what the planner derives its operations from, so
503    // the landing is the same landing; what it gains is the plan's own id,
504    // the journal that can take it back, and a recorded result.
505    let result = crate::plan::session::land(&landing)?;
506    for refused in result
507        .postconditions
508        .iter()
509        .filter(|postcondition| !postcondition.held)
510    {
511        lines.push(format!(
512            "FAIL {} did not hold: {}",
513            refused.id,
514            refused.detail.clone().unwrap_or_default()
515        ));
516    }
517    Ok(InitOutcome {
518        lines,
519        applied: true,
520        removed: result
521            .operations
522            .iter()
523            .filter(|operation| operation.kind == "remove-owned-file")
524            .map(|operation| operation.path.clone())
525            .collect(),
526    })
527}
528
529/// The one decision a front's flags still answer by name.
530///
531/// The rest travel as the options themselves. A flag and a decision are
532/// the same answer under two names, and rendering a recorded value back
533/// into its flag spelling only to parse it again is a round trip that can
534/// lose what it carries.
535fn profile_only(profile: ProfileId) -> crate::plan::decision::Selections {
536    let mut selections = crate::plan::decision::Selections::new();
537    selections.insert(
538        crate::plan::decision::id::PROFILE.to_string(),
539        profile.to_string(),
540    );
541    selections
542}