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