Skip to main content

spec_driven_docs/services/
status.rs

1//! Report an instance's state as data.
2//!
3//! Status answers what the verifier's text answers, but as one typed
4//! record a machine can branch on, and it treats a missing instance as an
5//! answer rather than an error. A corrupt manifest still raises: absence
6//! and breakage are different findings, and reporting a broken instance as
7//! absent would invite a destructive re-init.
8
9use camino::{Utf8Path, Utf8PathBuf};
10use serde::Serialize;
11
12use crate::domain::manifest::{DOCS_SCRATCH_VAR, PLAN_ZONE_VAR, PlanZone};
13use crate::domain::paths::{
14    self, ActivePaths, Paths, UserEnv, docs_scratch_location, plan_zone_location, recorded_paths,
15    variable,
16};
17use crate::domain::profile::{DocsRoot, ProfileId};
18use crate::domain::version::CanonVersion;
19use crate::error::AppError;
20use crate::services::verifier;
21
22/// The machine schema this report declares.
23///
24/// Adding a field within schema 2 is additive. Removing one, renaming one,
25/// or changing one's type is the next schema, because a reader that
26/// branched on the old shape cannot tell the two apart otherwise.
27pub const SCHEMA: &str = "sdd.status/2";
28
29/// How an instance's canon version relates to this binary's.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum Alignment {
33    /// The versions are equal.
34    Aligned,
35    /// The binary is newer; `sdd upgrade` moves the instance.
36    BinaryNewer,
37    /// The instance is newer; a newer `sdd` is required.
38    InstanceNewer,
39}
40
41/// One instance's state, as `sdd status` reports it.
42#[derive(Debug, Serialize)]
43pub struct StatusReport {
44    /// The machine schema this object declares.
45    pub schema: &'static str,
46    /// Whether the target carries an instance.
47    pub instance: bool,
48    /// The installed profile.
49    pub profile: Option<ProfileId>,
50    /// The instance's documentation root.
51    pub docs_root: Option<DocsRoot>,
52    /// The plan zone the instance records.
53    pub plan_zone: Option<PlanZone>,
54    /// The docs scratch the instance records.
55    pub docs_scratch: Option<Utf8PathBuf>,
56    /// What `SDD_PLAN_ZONE` carries here, when it is set. The variable
57    /// overrides the recorded value, so an audit needs both.
58    pub plan_zone_env: Option<String>,
59    /// What `SDD_DOCS_SCRATCH` carries here, when it is set.
60    pub docs_scratch_env: Option<String>,
61    /// The canon version that produced the instance.
62    pub canon_version: Option<CanonVersion>,
63    /// The version this binary carries.
64    pub binary_version: CanonVersion,
65    /// How the two versions relate.
66    pub alignment: Option<Alignment>,
67    /// Managed files missing, symlinked, or byte-drifted.
68    pub managed_drift: usize,
69    /// Adopted files awaiting reconciliation.
70    pub adopted_drift: usize,
71    /// Verification failures in total.
72    pub failures: usize,
73    /// Whether verification passes.
74    pub ok: Option<bool>,
75    /// Every path this binary can name for this target and this user.
76    ///
77    /// A skill reads this section rather than spelling a path of its own.
78    /// Every entry states what decided it, so a reader can tell a recorded
79    /// answer from a derived one.
80    pub paths: Paths,
81}
82
83/// What this target offers for the two locations the project owns.
84fn proposals(target: &Utf8Path, env: &UserEnv) -> paths::Proposals {
85    paths::proposals(env, |relative| target.join(relative).is_dir())
86}
87
88/// Every path, with no instance recorded.
89fn derived_paths(target: &Utf8Path, env: &UserEnv) -> Paths {
90    Paths {
91        user: env.user_paths(),
92        active: None,
93        candidates: paths::candidates(),
94        proposals: proposals(target, env),
95    }
96}
97
98fn absent(target: &Utf8Path) -> StatusReport {
99    let env = UserEnv::from_process();
100    StatusReport {
101        schema: SCHEMA,
102        paths: derived_paths(target, &env),
103        instance: false,
104        profile: None,
105        docs_root: None,
106        plan_zone: None,
107        docs_scratch: None,
108        plan_zone_env: variable(PLAN_ZONE_VAR),
109        docs_scratch_env: variable(DOCS_SCRATCH_VAR),
110        canon_version: None,
111        binary_version: CanonVersion::current(),
112        alignment: None,
113        managed_drift: 0,
114        adopted_drift: 0,
115        failures: 0,
116        ok: None,
117    }
118}
119
120/// Report the target's instance state.
121///
122/// # Errors
123///
124/// [`AppError::ManifestInvalid`] when a manifest exists but cannot be
125/// trusted, and I/O errors when the disk cannot be read. A missing
126/// manifest is a report, not an error.
127pub fn status(target: &Utf8Path) -> Result<StatusReport, AppError> {
128    let manifest = match verifier::read_manifest(target) {
129        Ok(manifest) => manifest,
130        Err(AppError::ManifestMissing(_)) => return Ok(absent(target)),
131        Err(error) => return Err(error),
132    };
133    let report = verifier::verify(
134        target,
135        &crate::release::embedded::EmbeddedReleaseBundle::new(),
136    )?;
137    let binary = CanonVersion::current();
138    let alignment = match manifest.canon_version.cmp(&binary) {
139        std::cmp::Ordering::Equal => Alignment::Aligned,
140        std::cmp::Ordering::Less => Alignment::BinaryNewer,
141        std::cmp::Ordering::Greater => Alignment::InstanceNewer,
142    };
143    let env = UserEnv::from_process();
144    let mut resolved = derived_paths(target, &env);
145    resolved.active = Some(ActivePaths {
146        profile: manifest.profile,
147        destinations: recorded_paths(manifest.docs_root),
148        plan_zone: plan_zone_location(&manifest.plan_zone, &env),
149        docs_scratch: docs_scratch_location(manifest.docs_scratch.as_deref(), &env),
150    });
151    Ok(StatusReport {
152        schema: SCHEMA,
153        paths: resolved,
154        instance: true,
155        profile: Some(manifest.profile),
156        docs_root: Some(manifest.docs_root),
157        plan_zone: Some(manifest.plan_zone),
158        docs_scratch: manifest.docs_scratch,
159        plan_zone_env: variable(PLAN_ZONE_VAR),
160        docs_scratch_env: variable(DOCS_SCRATCH_VAR),
161        canon_version: Some(manifest.canon_version),
162        binary_version: binary,
163        alignment: Some(alignment),
164        managed_drift: report.managed_drift,
165        adopted_drift: report.adopted_drift,
166        failures: report.failures,
167        ok: Some(report.failures == 0),
168    })
169}