Skip to main content

spec_driven_docs/domain/
manifest.rs

1//! The instance manifest: the persistent record of what an instance holds.
2//!
3//! Schema version 3. The manifest is what lets a later `sdd` distinguish
4//! managed drift from adopted reconciliation and its own version from the
5//! instance's. This module owns the shape and its parse-time invariants;
6//! reading it from disk, comparing it to bytes, and writing it belong to
7//! the services.
8
9use camino::{Utf8Path, Utf8PathBuf};
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry};
14use crate::domain::profile::{DocsRoot, ProfileId};
15use crate::domain::version::CanonVersion;
16
17/// The manifest schema this binary reads and writes.
18pub const SCHEMA_VERSION: u32 = 3;
19/// Where the canon is published.
20pub const CANON_SOURCE: &str = "https://github.com/gubasso/spec-driven-docs";
21// Every control path this tool names is declared in `domain::paths`, and
22// reaches its callers from here so that the names they already import stay
23// where they were.
24pub use crate::domain::paths::{DOCS_SCRATCH_VAR, INSTANCE_DIR, MANIFEST_PATH};
25
26/// Fields an earlier release of this schema version wrote and this one
27/// does not read.
28///
29/// A record carrying one parses as if the field were absent, and the next
30/// write omits it, so an instance in the field crosses the removal without
31/// an operator edit and without a schema bump.
32pub const RETIRED_FIELDS: &[&str] = &["plan_zone"];
33
34/// A `--docs-scratch` value the argument cannot mean.
35#[derive(Debug, Error, PartialEq, Eq)]
36#[error("{0}")]
37pub struct DeclaredPathError(String);
38
39/// A declared path, normalized: relative, non-empty, and `./` stripped.
40///
41/// `parents` says whether the path may leave the instance root. A docs
42/// scratch may, because staging beside the checkout is one of the offered
43/// answers.
44fn declared_path(value: &str, parents: bool) -> Result<Utf8PathBuf, DeclaredPathError> {
45    let value = value.trim();
46    if value.is_empty() {
47        return Err(DeclaredPathError("the path is empty".to_string()));
48    }
49    let path = Utf8Path::new(value);
50    if path.is_absolute() {
51        return Err(DeclaredPathError(format!("{value} is not relative")));
52    }
53    let mut normalized = Utf8PathBuf::new();
54    for component in path.components() {
55        match component {
56            camino::Utf8Component::CurDir => {}
57            camino::Utf8Component::ParentDir if parents => normalized.push(".."),
58            camino::Utf8Component::ParentDir => {
59                return Err(DeclaredPathError(format!("{value} leaves the repository")));
60            }
61            other => normalized.push(other.as_str()),
62        }
63    }
64    if normalized.as_str().is_empty() {
65        return Err(DeclaredPathError(format!("{value} names no directory")));
66    }
67    Ok(normalized)
68}
69
70/// Whether a recorded docs-scratch path is one a reader can resolve.
71///
72/// The same check the argument runs. A record reaches a reinstall through a
73/// permissive read, so without this a hand-edited value is carried forward
74/// and only the post-write verification catches it.
75///
76/// # Errors
77///
78/// [`DeclaredPathError`] for a path that is empty or absolute.
79pub fn validate_docs_scratch_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
80    declared_path(path.as_str(), true).map(|_| ())
81}
82
83/// Read the `--docs-scratch` argument.
84///
85/// `none` clears a recorded value. Without a clearing word an operator who
86/// declares a scratch by typo can change it but never return to the
87/// undeclared state.
88///
89/// # Errors
90///
91/// [`DeclaredPathError`] for a path that is empty or absolute.
92pub fn parse_docs_scratch(value: &str) -> Result<Option<Utf8PathBuf>, DeclaredPathError> {
93    if value.trim() == "none" {
94        return Ok(None);
95    }
96    declared_path(value, true).map(Some)
97}
98
99/// Everything an instance records about itself.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct Manifest {
103    /// Always [`SCHEMA_VERSION`] once parsed.
104    pub schema_version: u32,
105    /// The canon release that produced the installed payload.
106    pub canon_version: CanonVersion,
107    /// Where that canon is published.
108    pub canon_source: String,
109    /// The profile the instance was installed with.
110    pub profile: ProfileId,
111    /// The documentation root the gates read.
112    pub docs_root: DocsRoot,
113    /// When the instance was first installed; preserved across reinstalls.
114    pub installed_at: String,
115    /// Where material that is not a statement yet is kept.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub docs_scratch: Option<Utf8PathBuf>,
118    /// Byte projections the canon keeps owning.
119    pub managed_files: Vec<ManagedEntry>,
120    /// Files the instance owns against a recorded baseline.
121    pub adopted_files: Vec<AdoptedEntry>,
122    /// Marked regions the canon owns inside project files.
123    pub integration_blocks: Vec<IntegrationBlock>,
124}
125
126/// A manifest that could not be accepted.
127#[derive(Debug, Error)]
128pub enum ManifestParseError {
129    /// Not JSON, or JSON that does not fit the schema.
130    #[error("invalid manifest schema: {0}")]
131    Invalid(String),
132    /// A well-formed manifest of an older schema; upgradable, not readable.
133    #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
134    Older(u32),
135    /// A well-formed manifest of a newer schema; this binary is too old.
136    #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
137    Newer(u32),
138}
139
140impl Manifest {
141    /// Parse and validate a serialized manifest.
142    ///
143    /// # Errors
144    ///
145    /// [`ManifestParseError::Older`] / [`ManifestParseError::Newer`] when the
146    /// recorded schema version is not [`SCHEMA_VERSION`], and
147    /// [`ManifestParseError::Invalid`] for anything that does not fit the
148    /// schema or records no managed file.
149    pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
150        let mut value: serde_json::Value =
151            serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
152        match value
153            .get("schema_version")
154            .and_then(serde_json::Value::as_u64)
155        {
156            Some(v) if v == u64::from(SCHEMA_VERSION) => {}
157            Some(v) if v < u64::from(SCHEMA_VERSION) => {
158                return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
159            }
160            Some(v) => {
161                return Err(ManifestParseError::Newer(
162                    u32::try_from(v).unwrap_or(u32::MAX),
163                ));
164            }
165            None => {
166                return Err(ManifestParseError::Invalid(
167                    "no numeric schema_version".to_string(),
168                ));
169            }
170        }
171        // A field an earlier release of this schema wrote is dropped on
172        // read, so the record parses as it would have without it.
173        if let Some(object) = value.as_object_mut() {
174            for field in RETIRED_FIELDS {
175                object.remove(*field);
176            }
177        }
178        let manifest: Self = serde_json::from_value(value)
179            .map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
180        if manifest.managed_files.is_empty() {
181            return Err(ManifestParseError::Invalid(
182                "managed_files is empty".to_string(),
183            ));
184        }
185        // The declared location carries the same invariant the argument
186        // enforces. Without this the record is the weaker gate: a
187        // hand-edited absolute path makes a reader resolve outside the
188        // repository.
189        if let Some(path) = &manifest.docs_scratch
190            && let Err(error) = validate_docs_scratch_path(path)
191        {
192            return Err(ManifestParseError::Invalid(format!(
193                "docs_scratch: {error}"
194            )));
195        }
196        let mut paths = std::collections::BTreeSet::new();
197        for block in &manifest.integration_blocks {
198            if !paths.insert(&block.path) {
199                return Err(ManifestParseError::Invalid(format!(
200                    "duplicate integration block path: {}",
201                    block.path
202                )));
203            }
204        }
205        Ok(manifest)
206    }
207
208    /// Serialize in the canonical on-disk form: two-space indent, trailing newline.
209    #[must_use]
210    pub fn to_json(&self) -> String {
211        let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
212        json.push('\n');
213        json
214    }
215}
216
217/// The parts of an older manifest an upgrade needs.
218///
219/// Deliberately permissive: unknown fields of an older version pass through
220/// unread, so the upgrader can migrate any instance a previous distribution
221/// produced.
222#[derive(Debug, Clone, Deserialize)]
223pub struct LegacyManifest {
224    /// The recorded schema version; the upgrader requires an older one.
225    pub schema_version: u32,
226    /// The canon release the instance was installed from.
227    pub canon_version: CanonVersion,
228    /// The profile the instance was installed with.
229    pub profile: ProfileId,
230    /// The documentation root the gates read.
231    pub docs_root: DocsRoot,
232    /// When the instance was first installed.
233    pub installed_at: String,
234    /// Destination and hash of every file the older version managed.
235    pub managed_files: Vec<LegacyOwnedFile>,
236    /// The marked regions the older version owned inside project files.
237    ///
238    /// Version 1 recorded none, so the default carries that case. Dropping
239    /// the field instead would skip the edited-block conflict check on
240    /// every version-2 upgrade and overwrite the operator's edits.
241    #[serde(default)]
242    pub integration_blocks: Vec<IntegrationBlock>,
243}
244
245/// One version-1 managed entry: only what the conflict scan reads.
246#[derive(Debug, Clone, Deserialize)]
247pub struct LegacyOwnedFile {
248    /// Where the instance holds the file, relative to its root.
249    pub destination: Utf8PathBuf,
250    /// The bytes version 1 recorded for it.
251    pub sha256: crate::domain::ownership::Sha256,
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::domain::ownership::Sha256;
258
259    fn sample() -> Manifest {
260        Manifest {
261            schema_version: SCHEMA_VERSION,
262            canon_version: "0.2.0".parse().unwrap(),
263            canon_source: CANON_SOURCE.to_string(),
264            profile: ProfileId::KnowledgeBase,
265            docs_root: DocsRoot::UnderscoreDocs,
266            installed_at: "2026-08-25T00:00:00Z".to_string(),
267            docs_scratch: Some("scratch".into()),
268            managed_files: vec![ManagedEntry {
269                source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
270                destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
271                sha256: Sha256::of(b"x"),
272            }],
273            adopted_files: vec![],
274            integration_blocks: vec![],
275        }
276    }
277
278    #[test]
279    fn round_trips_through_json() {
280        let manifest = sample();
281        let json = manifest.to_json();
282        assert!(json.ends_with('\n'));
283        assert_eq!(Manifest::parse(&json).unwrap(), manifest);
284    }
285
286    #[test]
287    fn rejects_an_older_schema_as_upgradable() {
288        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
289        value["schema_version"] = 1.into();
290        assert!(matches!(
291            Manifest::parse(&value.to_string()),
292            Err(ManifestParseError::Older(1))
293        ));
294    }
295
296    #[test]
297    fn rejects_a_newer_schema_as_binary_too_old() {
298        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
299        value["schema_version"] = 4.into();
300        assert!(matches!(
301            Manifest::parse(&value.to_string()),
302            Err(ManifestParseError::Newer(4))
303        ));
304    }
305
306    /// A record written before the docs scratch was declared reads as the
307    /// project declaring none, rather than as a broken manifest.
308    #[test]
309    fn a_record_without_the_declared_location_defaults_it() {
310        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
311        value.as_object_mut().unwrap().remove("docs_scratch");
312        let manifest = Manifest::parse(&value.to_string()).unwrap();
313        assert_eq!(manifest.docs_scratch, None);
314    }
315
316    /// A field an earlier release of this schema wrote parses as absent,
317    /// whatever shape it carries, and the next write omits it.
318    #[test]
319    fn a_retired_field_is_dropped_on_read_and_omitted_on_write() {
320        for retired in RETIRED_FIELDS {
321            let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
322            value[*retired] = serde_json::json!({"kind": "tracked", "path": "docs/plan"});
323            let manifest = Manifest::parse(&value.to_string()).unwrap();
324            assert_eq!(manifest, sample());
325            assert!(!manifest.to_json().contains(retired));
326        }
327    }
328
329    #[test]
330    fn a_docs_scratch_may_leave_the_repository() {
331        assert_eq!(
332            parse_docs_scratch("../beside-the-checkout").unwrap(),
333            Some(Utf8PathBuf::from("../beside-the-checkout"))
334        );
335        assert!(parse_docs_scratch("/tmp/scratch").is_err());
336        assert!(parse_docs_scratch("").is_err());
337    }
338
339    /// The declared value has a clearing word, so a typo can be undone.
340    #[test]
341    fn the_declared_location_can_be_cleared() {
342        assert_eq!(parse_docs_scratch("none").unwrap(), None);
343        // And a directory literally named `none` is still reachable.
344        assert_eq!(
345            parse_docs_scratch("./none").unwrap(),
346            Some(Utf8PathBuf::from("none"))
347        );
348    }
349
350    /// The record carries the same invariant the argument enforces.
351    #[test]
352    fn a_recorded_location_the_argument_would_refuse_is_invalid() {
353        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
354        value["docs_scratch"] = "/tmp/scratch".into();
355        assert!(matches!(
356            Manifest::parse(&value.to_string()),
357            Err(ManifestParseError::Invalid(_))
358        ));
359    }
360
361    #[test]
362    fn rejects_unknown_fields_and_empty_managed_sets() {
363        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
364        value["canon_ref"] = "v0.2.0".into();
365        assert!(matches!(
366            Manifest::parse(&value.to_string()),
367            Err(ManifestParseError::Invalid(_))
368        ));
369
370        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
371        value["managed_files"] = serde_json::Value::Array(vec![]);
372        assert!(matches!(
373            Manifest::parse(&value.to_string()),
374            Err(ManifestParseError::Invalid(_))
375        ));
376    }
377
378    #[test]
379    fn legacy_manifest_reads_a_version_one_shape() {
380        let json = r#"{
381            "schema_version": 1,
382            "canon_version": "0.1.6",
383            "canon_source": "https://github.com/gubasso/spec-driven-docs",
384            "canon_ref": "pre-release",
385            "profile": "knowledge-base",
386            "docs_root": "_docs",
387            "installed_at": "2026-08-24T00:00:00Z",
388            "managed_files": [
389                {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
390                 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
391            ],
392            "adopted_files": [],
393            "integration_blocks": []
394        }"#;
395        let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
396        assert_eq!(legacy.schema_version, 1);
397        assert_eq!(legacy.canon_version.to_string(), "0.1.6");
398        assert_eq!(legacy.managed_files.len(), 1);
399        assert!(legacy.integration_blocks.is_empty());
400    }
401
402    /// A record of the shape version 2 actually wrote — the current one
403    /// without `docs_scratch`, which version 2 had no field for — parses,
404    /// and its integration blocks reach the upgrade. Without them the
405    /// edited-block conflict check is skipped on every version-2 hop.
406    #[test]
407    fn legacy_manifest_reads_the_version_two_shape() {
408        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
409        value["schema_version"] = 2.into();
410        let object = value.as_object_mut().unwrap();
411        object.remove("docs_scratch");
412        value["integration_blocks"] = serde_json::json!([{
413            "path": ".pre-commit-config.yaml",
414            "marker_hash": Sha256::of(b"block").to_string(),
415        }]);
416        let legacy: LegacyManifest = serde_json::from_str(&value.to_string()).unwrap();
417        assert_eq!(legacy.integration_blocks.len(), 1);
418        assert_eq!(legacy.integration_blocks[0].path, ".pre-commit-config.yaml");
419    }
420}