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::{MANIFEST_PATH, validate_docs_scratch_path};
14use crate::domain::paths::{AGENTS_DIGEST_PATH, HOOKS_CONFIG_PATH};
15use crate::domain::profile::{ProfileId, resolve_destination};
16use crate::domain::version::CanonVersion;
17use crate::error::AppError;
18
19/// What an installation was asked to do.
20#[derive(Debug, Clone)]
21pub struct InitOptions {
22    /// The absolute target repository.
23    pub target: Utf8PathBuf,
24    /// The profile to project.
25    pub profile: ProfileId,
26    /// Write even into a non-empty target with no instance.
27    pub apply: bool,
28    /// Preview only, regardless of the target's state.
29    pub dry_run: bool,
30    /// The docs scratch to record. `None` keeps whatever is recorded, and
31    /// `Some(None)` clears it.
32    pub docs_scratch: Option<Option<Utf8PathBuf>>,
33    /// Paths to record under `reserved:` in the instance's declaration. An
34    /// empty list keeps whatever is recorded.
35    pub reserve: Vec<String>,
36    /// The writing-style selection to record in the declaration. `None`
37    /// keeps whatever is recorded.
38    pub writing_style: Option<crate::domain::instance_config::WritingStyle>,
39}
40
41/// What an installation did.
42#[derive(Debug)]
43pub struct InitOutcome {
44    /// Every line to print: the proposed destinations, then any notices.
45    pub lines: Vec<String>,
46    /// Whether files were written.
47    pub applied: bool,
48    /// Every destination the landing took back, relative to the target.
49    pub removed: Vec<String>,
50}
51
52fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
53    if !target.is_absolute() {
54        return Err(AppError::Usage("target must be absolute".to_string()));
55    }
56    if !target.is_dir() {
57        return Err(AppError::Usage(format!("unresolved target: {target}")));
58    }
59    let canonical = std::fs::canonicalize(target)?;
60    let canonical = Utf8PathBuf::from_path_buf(canonical)
61        .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
62    if canonical.as_str().chars().all(|c| c == '/') {
63        return Err(AppError::Usage("refusing root target".to_string()));
64    }
65    let mut ancestor = Some(canonical.as_path());
66    while let Some(dir) = ancestor {
67        if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
68            && cargo.contains("name = \"spec-driven-docs\"")
69        {
70            return Err(AppError::Usage(
71                "target is inside the canon checkout".to_string(),
72            ));
73        }
74        ancestor = dir.parent();
75    }
76    Ok(canonical)
77}
78
79fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
80    for entry in target.read_dir_utf8()? {
81        let entry = entry?;
82        if entry.file_name() != ".git" {
83            return Ok(true);
84        }
85    }
86    Ok(false)
87}
88
89/// One field of whatever manifest the target already carries.
90///
91/// Read as free JSON rather than through [`Manifest::parse`]: a reinstall
92/// over a record of another schema version must still carry the operator's
93/// declared values forward, and a typed parse would refuse to read it.
94pub(crate) fn recorded_field(target: &Utf8Path, key: &str) -> Option<serde_json::Value> {
95    std::fs::read_to_string(target.join(MANIFEST_PATH))
96        .ok()
97        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
98        .and_then(|value| value.get(key).cloned())
99        .filter(|value| !value.is_null())
100}
101
102fn installed_at(target: &Utf8Path) -> String {
103    recorded_field(target, "installed_at")
104        .and_then(|value| value.as_str().map(String::from))
105        .unwrap_or_else(|| {
106            jiff::Timestamp::now()
107                .strftime("%Y-%m-%dT%H:%M:%SZ")
108                .to_string()
109        })
110}
111
112/// The docs scratch to record: the flag, else the recorded value, else none.
113///
114/// An omitted flag never clears a declared value. `sdd upgrade` reinstalls
115/// with no flag at all, so "absent means the default" would erase the
116/// operator's declaration on every upgrade. The flag is two-level on
117/// purpose: absent keeps what is recorded, and `--docs-scratch none` clears
118/// it.
119///
120/// # Errors
121///
122/// [`AppError::ManifestInvalid`] when the recorded value is not a string.
123pub(crate) fn resolved_docs_scratch(
124    target: &Utf8Path,
125    flag: Option<&Option<Utf8PathBuf>>,
126) -> Result<Option<Utf8PathBuf>, AppError> {
127    if let Some(declared) = flag {
128        return Ok(declared.clone());
129    }
130    let Some(recorded) = recorded_field(target, "docs_scratch") else {
131        return Ok(None);
132    };
133    let path = recorded
134        .as_str()
135        .filter(|path| !path.is_empty())
136        .map(Utf8PathBuf::from)
137        .ok_or_else(|| {
138            AppError::ManifestInvalid(format!(
139                "the recorded docs_scratch is not a path ({recorded}); \
140                 re-declare it with --docs-scratch"
141            ))
142        })?;
143    if let Err(error) = validate_docs_scratch_path(&path) {
144        return Err(AppError::ManifestInvalid(format!(
145            "the recorded docs_scratch is not usable ({error}); \
146             re-declare it with --docs-scratch"
147        )));
148    }
149    Ok(Some(path))
150}
151
152/// Every byte one landing would put in a target, and what it would say.
153///
154/// The planner takes this rather than deriving it a second time: what a
155/// release lands into a target is one computation, and two of them would
156/// be two places for one rule to drift.
157#[derive(Debug, Clone)]
158pub struct TargetState {
159    /// Each destination and the bytes that would go there.
160    pub files: Vec<(Utf8PathBuf, Vec<u8>)>,
161    /// What the operator would be told.
162    pub lines: Vec<String>,
163}
164
165/// What this binary would put in a target, and what it would say.
166///
167/// Observation happens here and projection happens in [`crate::candidate`].
168/// Everything this function reads from the target is a value it hands over,
169/// so the bytes a stage renders and the bytes a landing writes come out of
170/// one pure pass over the same evidence.
171///
172/// # Errors
173///
174/// [`AppError::Refused`] when this release declares no such profile, when a
175/// marked region cannot be read, or when the root author-instructions file
176/// is a link, and I/O errors reading the target.
177pub fn compute_target_state(
178    target: &Utf8Path,
179    options: &InitOptions,
180) -> Result<TargetState, AppError> {
181    let candidate = candidate_for(target, options)?;
182    let mut lines: Vec<String> = Vec::new();
183    for destination in &candidate.destinations {
184        lines.push(destination.path.to_string());
185    }
186    lines.extend(candidate.notes.iter().cloned());
187    lines.push(MANIFEST_PATH.to_string());
188    Ok(TargetState {
189        files: candidate.files(),
190        lines,
191    })
192}
193
194/// The candidate this binary renders for one target.
195///
196/// # Errors
197///
198/// As [`compute_target_state`].
199pub fn candidate_for(
200    target: &Utf8Path,
201    options: &InitOptions,
202) -> Result<crate::candidate::Candidate, AppError> {
203    crate::candidate::project(&gather(target, options)?)
204}
205
206/// The target a landing or a stage will read, canonical and known-good.
207///
208/// # Errors
209///
210/// [`AppError::Usage`] for a path no verb can mean: relative, absent, the
211/// filesystem root, or inside the canon checkout.
212pub fn resolved_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
213    canonical_target(target)
214}
215
216/// Read the target once, so the projection never has to.
217fn gather(target: &Utf8Path, options: &InitOptions) -> Result<crate::candidate::Input, AppError> {
218    let docs_root = crate::candidate::docs_root_of(options.profile)?;
219
220    // What the target already records as adopted. A destination that holds
221    // project content and is recorded nowhere is preserved and noted: the
222    // seed does not land, and the project should know the specification it
223    // would have received.
224    let recorded_adopted: Vec<String> = recorded_field(target, "adopted_files")
225        .and_then(|value| {
226            value.as_array().map(|entries| {
227                entries
228                    .iter()
229                    .filter_map(|entry| entry.get("destination")?.as_str().map(String::from))
230                    .collect()
231            })
232        })
233        .unwrap_or_default();
234
235    let mut existing = std::collections::BTreeMap::new();
236    for projection in &crate::domain::profile::DECLARATION.adopted {
237        let destination = resolve_destination(&projection.destination, docs_root);
238        let path = target.join(&destination);
239        if path.is_file() {
240            existing.insert(destination, std::fs::read(&path)?);
241        }
242    }
243
244    let hooks_path = target.join(HOOKS_CONFIG_PATH);
245    let hooks_host = if hooks_path.is_file() {
246        std::fs::read_to_string(&hooks_path)?
247    } else {
248        String::new()
249    };
250
251    // The root AGENTS.md documentation block routes authors to the context they
252    // load before editing. A symlinked host is refused before it is read, so a
253    // link cannot redirect the read outside the target.
254    let agents_path = target.join(AGENTS_DIGEST_PATH);
255    if agents_path.is_symlink() {
256        return Err(AppError::Refused(
257            "AGENTS.md is a symlink; refusing to write the documentation block through it"
258                .to_string(),
259        ));
260    }
261    let agents_host = if agents_path.is_file() {
262        std::fs::read_to_string(&agents_path)?
263    } else {
264        String::new()
265    };
266
267    Ok(crate::candidate::Input {
268        profile: options.profile,
269        version: CanonVersion::current(),
270        installed_at: installed_at(target),
271        docs_scratch: resolved_docs_scratch(target, options.docs_scratch.as_ref())?,
272        reserve: options.reserve.clone(),
273        writing_style: options.writing_style.clone(),
274        evidence: crate::candidate::Evidence {
275            existing,
276            recorded_adopted,
277            hooks_host,
278            agents_host,
279        },
280    })
281}
282
283/// Install or reinstall an instance.
284///
285/// # Errors
286///
287/// [`AppError::Usage`] for a target the arguments cannot mean,
288/// [`AppError::Marker`] for a configuration whose markers cannot be
289/// trusted, and [`AppError::Refused`] when a destination escapes the
290/// target, holds bytes no record accounts for, or cannot be written.
291pub fn init(
292    options: &InitOptions,
293    intent: crate::landing::classify::Intent,
294) -> Result<InitOutcome, AppError> {
295    init_holding(None, options, intent)
296}
297
298/// Install or reinstall an instance under a lock the caller already holds.
299///
300/// A verb that observed the target before it decided to land must hold the
301/// target across both, so the tree it decided from is the tree it writes.
302///
303/// # Errors
304///
305/// As [`init`].
306pub fn init_holding(
307    held: Option<crate::transaction::lock::Lock>,
308    options: &InitOptions,
309    intent: crate::landing::classify::Intent,
310) -> Result<InitOutcome, AppError> {
311    let target = canonical_target(&options.target)?;
312    // The target is known-good before it is classified, so an argument
313    // this verb cannot mean is a usage answer rather than a walk of
314    // whatever the argument happened to name.
315    crate::commands::front::serves(intent, &target)?;
316    let forced_dry = !options.apply
317        && !options.dry_run
318        && target_has_content(&target)?
319        && !target.join(MANIFEST_PATH).is_file();
320    let dry = options.dry_run || forced_dry;
321
322    // A preview reads and writes nothing, so it needs no lock. An apply
323    // holds one across the observation as well as the writes: a candidate
324    // rendered before the lock would describe a target another run could
325    // still be changing. A caller that already holds it passes it in,
326    // because taking it twice from one process proves nothing.
327    let held = match (dry, held) {
328        (true, _) => None,
329        (false, Some(held)) => Some(held),
330        (false, None) => Some(crate::landing::lock::hold(&target)?),
331    };
332
333    // A profile carries the documentation root, so changing it moves every
334    // adopted document to a new home and leaves the old corpus where it
335    // is. That is a migration somebody asks for deliberately, never a
336    // consequence of running a landing verb with a different flag.
337    if let Some(recorded) = recorded_field(&target, "profile").and_then(|value| {
338        ProfileId::every().find(|profile| Some(profile.as_str()) == value.as_str())
339    }) && recorded != options.profile
340    {
341        return Err(AppError::Refused(format!(
342            "{target} records the {recorded} profile and this run asks for {}; \
343             moving a profile moves the documentation root, which is a migration to ask for deliberately",
344            options.profile
345        )));
346    }
347
348    let candidate = candidate_for(&target, options)?;
349    let mut lines: Vec<String> = candidate
350        .destinations
351        .iter()
352        .map(|destination| destination.path.to_string())
353        .collect();
354    lines.extend(candidate.notes.iter().cloned());
355    lines.push(MANIFEST_PATH.to_string());
356
357    if dry {
358        if forced_dry {
359            lines.push(
360                "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
361                    .to_string(),
362            );
363        }
364        lines.push("DRY RUN: no files written".to_string());
365        return Ok(InitOutcome {
366            lines,
367            applied: false,
368            removed: Vec::new(),
369        });
370    }
371
372    let outcome = crate::landing::apply::land(&target, &candidate, &recorded(&target))?;
373    drop(held);
374    Ok(InitOutcome {
375        lines,
376        applied: true,
377        removed: outcome.removed,
378    })
379}
380
381/// What the target's own record says this tool owns today.
382///
383/// Read as free JSON, so a record an older release wrote still says which
384/// files this tool may refresh, which regions it may re-splice, and which
385/// files it may take back.
386pub(crate) fn recorded(target: &Utf8Path) -> crate::landing::apply::Recorded {
387    crate::landing::apply::Recorded {
388        managed: digests(target, "managed_files", "destination", "sha256"),
389        integration: digests(target, "integration_blocks", "path", "marker_hash"),
390    }
391}
392
393/// One recorded list, as pairs of destination and digest.
394fn digests(
395    target: &Utf8Path,
396    key: &str,
397    name: &str,
398    digest: &str,
399) -> Vec<(String, crate::domain::ownership::Sha256)> {
400    let Some(value) = recorded_field(target, key) else {
401        return Vec::new();
402    };
403    let Some(entries) = value.as_array() else {
404        return Vec::new();
405    };
406    entries
407        .iter()
408        .filter_map(|entry| {
409            let destination = entry.get(name)?.as_str()?.to_string();
410            let held = entry.get(digest)?.as_str()?;
411            let held = held.parse::<crate::domain::ownership::Sha256>().ok()?;
412            Some((destination, held))
413        })
414        .collect()
415}