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::{CANON_SOURCE, MANIFEST_PATH, Manifest, SCHEMA_VERSION};
17use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry, Sha256};
18use crate::domain::profile::{ProfileId, resolve_destination};
19use crate::domain::version::CanonVersion;
20use crate::error::AppError;
21use crate::services::hooks_render::{RenderOptions, render_block};
22use crate::services::verifier;
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}
36
37/// What an installation did.
38#[derive(Debug)]
39pub struct InitOutcome {
40    /// Every line to print: the proposed destinations, then any notices.
41    pub lines: Vec<String>,
42    /// Whether files were written.
43    pub applied: bool,
44}
45
46fn canonical_target(target: &Utf8Path) -> Result<Utf8PathBuf, AppError> {
47    if !target.is_absolute() {
48        return Err(AppError::Usage("target must be absolute".to_string()));
49    }
50    if !target.is_dir() {
51        return Err(AppError::Usage(format!("unresolved target: {target}")));
52    }
53    let canonical = std::fs::canonicalize(target)?;
54    let canonical = Utf8PathBuf::from_path_buf(canonical)
55        .map_err(|p| AppError::Usage(format!("target is not UTF-8: {}", p.display())))?;
56    if canonical.as_str().chars().all(|c| c == '/') {
57        return Err(AppError::Usage("refusing root target".to_string()));
58    }
59    let mut ancestor = Some(canonical.as_path());
60    while let Some(dir) = ancestor {
61        if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
62            && cargo.contains("name = \"spec-driven-docs\"")
63        {
64            return Err(AppError::Usage(
65                "target is inside the canon checkout".to_string(),
66            ));
67        }
68        ancestor = dir.parent();
69    }
70    Ok(canonical)
71}
72
73fn target_has_content(target: &Utf8Path) -> Result<bool, AppError> {
74    for entry in target.read_dir_utf8()? {
75        let entry = entry?;
76        if entry.file_name() != ".git" {
77            return Ok(true);
78        }
79    }
80    Ok(false)
81}
82
83fn installed_at(target: &Utf8Path) -> String {
84    std::fs::read_to_string(target.join(MANIFEST_PATH))
85        .ok()
86        .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
87        .and_then(|value| {
88            value
89                .get("installed_at")
90                .and_then(|v| v.as_str())
91                .map(String::from)
92        })
93        .unwrap_or_else(|| {
94            jiff::Timestamp::now()
95                .strftime("%Y-%m-%dT%H:%M:%SZ")
96                .to_string()
97        })
98}
99
100struct TargetState {
101    files: Vec<(Utf8PathBuf, Vec<u8>)>,
102    lines: Vec<String>,
103}
104
105fn compute_target_state(target: &Utf8Path, profile: ProfileId) -> Result<TargetState, AppError> {
106    let declaration = profile.profile();
107    let mut files: Vec<(Utf8PathBuf, Vec<u8>)> = Vec::new();
108    let mut lines = Vec::new();
109    let mut managed_entries = Vec::new();
110    let mut adopted_entries = Vec::new();
111
112    for projection in declaration.managed {
113        let bytes = crate::embedded::asset(projection.source)
114            .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
115        let destination = Utf8PathBuf::from(projection.destination);
116        managed_entries.push(ManagedEntry {
117            source: projection.source.into(),
118            destination: destination.clone(),
119            sha256: Sha256::of(bytes),
120        });
121        lines.push(destination.to_string());
122        files.push((destination, bytes.to_vec()));
123    }
124
125    for projection in declaration.adopted {
126        let seed = crate::embedded::asset(projection.source)
127            .ok_or_else(|| anyhow::anyhow!("payload asset missing: {}", projection.source))?;
128        let destination = resolve_destination(projection.destination, declaration.docs_root);
129        let existing = target.join(&destination);
130        let bytes = if existing.is_file() {
131            std::fs::read(&existing)?
132        } else {
133            seed.to_vec()
134        };
135        adopted_entries.push(AdoptedEntry {
136            source: projection.source.into(),
137            destination: destination.clone(),
138            sha256: Sha256::of(&bytes),
139            baseline_sha256: Sha256::of(seed),
140        });
141        lines.push(destination.to_string());
142        files.push((destination, bytes));
143    }
144
145    let config_path = target.join(".pre-commit-config.yaml");
146    let host = if config_path.is_file() {
147        std::fs::read_to_string(&config_path)?
148    } else {
149        "repos:\n".to_string()
150    };
151    let (base, _) = crate::domain::marker::split_block(&host)?;
152    let indent = crate::domain::marker::splice_indent(&base)?;
153    let block = render_block(&RenderOptions {
154        docs_root: declaration.docs_root.to_string(),
155        indent,
156        ..RenderOptions::default()
157    });
158    let spliced = crate::domain::marker::splice(&base, &block)?;
159    let marker_hash = crate::domain::marker::block_hash(&spliced)
160        .ok_or_else(|| anyhow::anyhow!("the rendered block lost its markers"))?;
161    lines.push(".pre-commit-config.yaml".to_string());
162    files.push((
163        Utf8PathBuf::from(".pre-commit-config.yaml"),
164        spliced.into_bytes(),
165    ));
166
167    let manifest = Manifest {
168        schema_version: SCHEMA_VERSION,
169        canon_version: CanonVersion::current(),
170        canon_source: CANON_SOURCE.to_string(),
171        profile,
172        docs_root: declaration.docs_root,
173        installed_at: installed_at(target),
174        managed_files: managed_entries,
175        adopted_files: adopted_entries,
176        integration_blocks: vec![IntegrationBlock {
177            path: ".pre-commit-config.yaml".into(),
178            marker_hash,
179        }],
180    };
181    lines.push(MANIFEST_PATH.to_string());
182    files.push((
183        Utf8PathBuf::from(MANIFEST_PATH),
184        manifest.to_json().into_bytes(),
185    ));
186
187    Ok(TargetState { files, lines })
188}
189
190fn refusal_line(destination: &Utf8Path, refusal: &DestinationRefusal) -> String {
191    match refusal {
192        DestinationRefusal::SymlinkEscape => {
193            format!("destination escapes the target through a symlink: {destination}")
194        }
195        DestinationRefusal::FileBlocksDirectory(blocked) => {
196            format!("a file blocks a directory the install needs: {blocked}")
197        }
198        DestinationRefusal::NotARegularFile => {
199            format!("destination exists and is not a regular file: {destination}")
200        }
201    }
202}
203
204fn apply(target: &Utf8Path, state: &TargetState) -> Result<(), AppError> {
205    let mut ordered: Vec<&(Utf8PathBuf, Vec<u8>)> = state.files.iter().collect();
206    ordered.sort_by(|a, b| a.0.as_str().as_bytes().cmp(b.0.as_str().as_bytes()));
207
208    for (destination, _) in &ordered {
209        check_destination(target, destination)
210            .map_err(|refusal| AppError::Refused(refusal_line(destination, &refusal)))?;
211    }
212
213    let mut backups: BTreeMap<Utf8PathBuf, Option<Vec<u8>>> = BTreeMap::new();
214    let rollback = |backups: &BTreeMap<Utf8PathBuf, Option<Vec<u8>>>| -> Vec<Utf8PathBuf> {
215        let mut unrestored = Vec::new();
216        for (destination, previous) in backups {
217            let full = target.join(destination);
218            let restored = previous.as_ref().map_or_else(
219                || std::fs::remove_file(&full).is_ok() || !full.exists(),
220                |bytes| write_file(&full, bytes).is_ok(),
221            );
222            if !restored {
223                unrestored.push(destination.clone());
224            }
225        }
226        unrestored
227    };
228    let abort = |unrestored: Vec<Utf8PathBuf>| {
229        if unrestored.is_empty() {
230            AppError::Refused("apply aborted; the target was restored".to_string())
231        } else {
232            let paths: Vec<&str> = unrestored.iter().map(|p| p.as_str()).collect();
233            AppError::Refused(format!(
234                "apply aborted and restoration is incomplete; verify by hand: {}",
235                paths.join(" ")
236            ))
237        }
238    };
239
240    for (destination, _) in &ordered {
241        let full = target.join(destination);
242        let previous = if full.is_file() {
243            Some(std::fs::read(&full).map_err(|source| {
244                AppError::Refused(format!("cannot back up {destination}: {source}"))
245            })?)
246        } else {
247            None
248        };
249        backups.insert((*destination).clone(), previous);
250    }
251
252    let write_all = || -> std::io::Result<()> {
253        for (destination, bytes) in &ordered {
254            if destination.as_str() != MANIFEST_PATH {
255                write_file(&target.join(destination), bytes)?;
256            }
257        }
258        for (destination, bytes) in &ordered {
259            if destination.as_str() == MANIFEST_PATH {
260                write_file(&target.join(destination), bytes)?;
261            }
262        }
263        Ok(())
264    };
265
266    if write_all().is_err() {
267        return Err(abort(rollback(&backups)));
268    }
269
270    match verifier::verify(target) {
271        Ok(report) if report.failures == 0 => Ok(()),
272        _ => Err(abort(rollback(&backups))),
273    }
274}
275
276/// Install or reinstall an instance.
277///
278/// # Errors
279///
280/// [`AppError::Usage`] for a target the arguments cannot mean,
281/// [`AppError::Marker`] for a configuration whose markers cannot be trusted,
282/// and [`AppError::Refused`] when the apply could not complete — the target
283/// is restored before that returns.
284pub fn init(options: &InitOptions) -> Result<InitOutcome, AppError> {
285    let target = canonical_target(&options.target)?;
286    let forced_dry = !options.apply
287        && !options.dry_run
288        && target_has_content(&target)?
289        && !target.join(MANIFEST_PATH).is_file();
290    let dry = options.dry_run || forced_dry;
291
292    let state = compute_target_state(&target, options.profile)?;
293    let mut lines = state.lines.clone();
294
295    if dry {
296        if forced_dry {
297            lines.push(
298                "DRY RUN: the target is a non-empty repository with no instance; re-run with --apply to write these files"
299                    .to_string(),
300            );
301        }
302        lines.push("DRY RUN: no files written".to_string());
303        return Ok(InitOutcome {
304            lines,
305            applied: false,
306        });
307    }
308
309    apply(&target, &state)?;
310    Ok(InitOutcome {
311        lines,
312        applied: true,
313    })
314}