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, PLAN_ZONE_VAR};
25
26/// Where the project's planning tool writes its entry documents.
27///
28/// The kind is part of the value because the three absent cases are not one
29/// case. A tracked zone is a directory every clone has, so a command may
30/// check it and an absent directory is drift. An untracked zone and a zone
31/// reached through [`PLAN_ZONE_VAR`] are absent on a fresh clone, so the
32/// same failure there would report a layout the project never promised.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
35pub enum PlanZone {
36    /// A repository-relative directory under version control.
37    Tracked {
38        /// Where the zone sits, relative to the instance root.
39        path: Utf8PathBuf,
40    },
41    /// A repository-relative directory version control does not carry.
42    Untracked {
43        /// Where the zone sits, relative to the instance root.
44        path: Utf8PathBuf,
45    },
46    /// Wherever [`PLAN_ZONE_VAR`] resolves at run time.
47    Env,
48    /// The project keeps no plan zone.
49    #[default]
50    None,
51}
52
53/// A `--plan-zone` or `--docs-scratch` value the arguments cannot mean.
54#[derive(Debug, Error, PartialEq, Eq)]
55#[error("{0}")]
56pub struct DeclaredPathError(String);
57
58/// A declared path, normalized: relative, non-empty, and `./` stripped.
59///
60/// `parents` says whether the path may leave the instance root. A plan zone
61/// may not, because a gate resolves it against that root. A docs scratch
62/// may, because staging beside the checkout is one of the offered answers.
63fn declared_path(value: &str, parents: bool) -> Result<Utf8PathBuf, DeclaredPathError> {
64    let value = value.trim();
65    if value.is_empty() {
66        return Err(DeclaredPathError("the path is empty".to_string()));
67    }
68    let path = Utf8Path::new(value);
69    if path.is_absolute() {
70        return Err(DeclaredPathError(format!("{value} is not relative")));
71    }
72    let mut normalized = Utf8PathBuf::new();
73    for component in path.components() {
74        match component {
75            camino::Utf8Component::CurDir => {}
76            camino::Utf8Component::ParentDir if parents => normalized.push(".."),
77            camino::Utf8Component::ParentDir => {
78                return Err(DeclaredPathError(format!("{value} leaves the repository")));
79            }
80            other => normalized.push(other.as_str()),
81        }
82    }
83    if normalized.as_str().is_empty() {
84        return Err(DeclaredPathError(format!("{value} names no directory")));
85    }
86    Ok(normalized)
87}
88
89impl PlanZone {
90    /// Read the `--plan-zone` argument's four forms.
91    ///
92    /// `none` and `env` are words rather than paths, so a directory with
93    /// either name is written `./none` or `./env`.
94    ///
95    /// # Errors
96    ///
97    /// [`DeclaredPathError`] for a path that is empty, absolute, or leaves
98    /// the repository.
99    pub fn parse(value: &str) -> Result<Self, DeclaredPathError> {
100        match value.trim() {
101            "none" => Ok(Self::None),
102            "env" => Ok(Self::Env),
103            // The vocabulary offers one prefix, so the symmetric spelling is
104            // a usage mistake rather than a directory named `tracked:`.
105            rest if rest.starts_with("tracked:") => Err(DeclaredPathError(
106                "a tracked zone is written as the bare path; `untracked:` is the only prefix"
107                    .to_string(),
108            )),
109            rest => match rest.strip_prefix("untracked:") {
110                Some(path) => Ok(Self::Untracked {
111                    path: declared_path(path, false)?,
112                }),
113                None => Ok(Self::Tracked {
114                    path: declared_path(rest, false)?,
115                }),
116            },
117        }
118    }
119
120    /// The recorded path, whatever the kind carries.
121    #[must_use]
122    pub const fn path(&self) -> Option<&Utf8PathBuf> {
123        match self {
124            Self::Tracked { path } | Self::Untracked { path } => Some(path),
125            Self::Env | Self::None => None,
126        }
127    }
128}
129
130/// Whether a recorded plan-zone path is one a gate can resolve.
131///
132/// The same check the argument runs. A record reaches a reinstall through a
133/// permissive read, so without this a hand-edited value is carried forward
134/// and only the post-write verification catches it.
135///
136/// # Errors
137///
138/// [`DeclaredPathError`] for a path that is empty, absolute, or leaves the
139/// repository.
140pub fn validate_plan_zone_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
141    declared_path(path.as_str(), false).map(|_| ())
142}
143
144/// Whether a recorded docs-scratch path is one a reader can resolve.
145///
146/// # Errors
147///
148/// [`DeclaredPathError`] for a path that is empty or absolute.
149pub fn validate_docs_scratch_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
150    declared_path(path.as_str(), true).map(|_| ())
151}
152
153/// Read the `--docs-scratch` argument.
154///
155/// `none` clears a recorded value, mirroring `--plan-zone none`. Without a
156/// clearing word an operator who declares a scratch by typo can change it
157/// but never return to the undeclared state.
158///
159/// # Errors
160///
161/// [`DeclaredPathError`] for a path that is empty or absolute.
162pub fn parse_docs_scratch(value: &str) -> Result<Option<Utf8PathBuf>, DeclaredPathError> {
163    if value.trim() == "none" {
164        return Ok(None);
165    }
166    declared_path(value, true).map(Some)
167}
168
169/// Everything an instance records about itself.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct Manifest {
173    /// Always [`SCHEMA_VERSION`] once parsed.
174    pub schema_version: u32,
175    /// The canon release that produced the installed payload.
176    pub canon_version: CanonVersion,
177    /// Where that canon is published.
178    pub canon_source: String,
179    /// The profile the instance was installed with.
180    pub profile: ProfileId,
181    /// The documentation root the gates read.
182    pub docs_root: DocsRoot,
183    /// When the instance was first installed; preserved across reinstalls.
184    pub installed_at: String,
185    /// Where the planning tool writes entry documents.
186    #[serde(default)]
187    pub plan_zone: PlanZone,
188    /// Where material that is not a statement yet is kept.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub docs_scratch: Option<Utf8PathBuf>,
191    /// Byte projections the canon keeps owning.
192    pub managed_files: Vec<ManagedEntry>,
193    /// Files the instance owns against a recorded baseline.
194    pub adopted_files: Vec<AdoptedEntry>,
195    /// Marked regions the canon owns inside project files.
196    pub integration_blocks: Vec<IntegrationBlock>,
197}
198
199/// A manifest that could not be accepted.
200#[derive(Debug, Error)]
201pub enum ManifestParseError {
202    /// Not JSON, or JSON that does not fit the schema.
203    #[error("invalid manifest schema: {0}")]
204    Invalid(String),
205    /// A well-formed manifest of an older schema; upgradable, not readable.
206    #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
207    Older(u32),
208    /// A well-formed manifest of a newer schema; this binary is too old.
209    #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
210    Newer(u32),
211}
212
213impl Manifest {
214    /// Parse and validate a serialized manifest.
215    ///
216    /// # Errors
217    ///
218    /// [`ManifestParseError::Older`] / [`ManifestParseError::Newer`] when the
219    /// recorded schema version is not [`SCHEMA_VERSION`], and
220    /// [`ManifestParseError::Invalid`] for anything that does not fit the
221    /// schema or records no managed file.
222    pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
223        let value: serde_json::Value =
224            serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
225        match value
226            .get("schema_version")
227            .and_then(serde_json::Value::as_u64)
228        {
229            Some(v) if v == u64::from(SCHEMA_VERSION) => {}
230            Some(v) if v < u64::from(SCHEMA_VERSION) => {
231                return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
232            }
233            Some(v) => {
234                return Err(ManifestParseError::Newer(
235                    u32::try_from(v).unwrap_or(u32::MAX),
236                ));
237            }
238            None => {
239                return Err(ManifestParseError::Invalid(
240                    "no numeric schema_version".to_string(),
241                ));
242            }
243        }
244        let manifest: Self = serde_json::from_value(value)
245            .map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
246        if manifest.managed_files.is_empty() {
247            return Err(ManifestParseError::Invalid(
248                "managed_files is empty".to_string(),
249            ));
250        }
251        // The declared locations carry the same invariants the arguments
252        // enforce. Without this the record is the weaker gate: a hand-edited
253        // empty path makes the typed-clause gate skip a zone the project
254        // declared tracked, and an absolute one makes it read outside the
255        // repository, because a gate resolves the value against the root.
256        if let Some(path) = manifest.plan_zone.path()
257            && let Err(error) = validate_plan_zone_path(path)
258        {
259            return Err(ManifestParseError::Invalid(format!("plan_zone: {error}")));
260        }
261        if let Some(path) = &manifest.docs_scratch
262            && let Err(error) = validate_docs_scratch_path(path)
263        {
264            return Err(ManifestParseError::Invalid(format!(
265                "docs_scratch: {error}"
266            )));
267        }
268        let mut paths = std::collections::BTreeSet::new();
269        for block in &manifest.integration_blocks {
270            if !paths.insert(&block.path) {
271                return Err(ManifestParseError::Invalid(format!(
272                    "duplicate integration block path: {}",
273                    block.path
274                )));
275            }
276        }
277        Ok(manifest)
278    }
279
280    /// Serialize in the canonical on-disk form: two-space indent, trailing newline.
281    #[must_use]
282    pub fn to_json(&self) -> String {
283        let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
284        json.push('\n');
285        json
286    }
287}
288
289/// The parts of an older manifest an upgrade needs.
290///
291/// Deliberately permissive: unknown fields of an older version pass through
292/// unread, so the upgrader can migrate any instance a previous distribution
293/// produced.
294#[derive(Debug, Clone, Deserialize)]
295pub struct LegacyManifest {
296    /// The recorded schema version; the upgrader requires an older one.
297    pub schema_version: u32,
298    /// The canon release the instance was installed from.
299    pub canon_version: CanonVersion,
300    /// The profile the instance was installed with.
301    pub profile: ProfileId,
302    /// The documentation root the gates read.
303    pub docs_root: DocsRoot,
304    /// When the instance was first installed.
305    pub installed_at: String,
306    /// Destination and hash of every file the older version managed.
307    pub managed_files: Vec<LegacyOwnedFile>,
308    /// The marked regions the older version owned inside project files.
309    ///
310    /// Version 1 recorded none, so the default carries that case. Dropping
311    /// the field instead would skip the edited-block conflict check on
312    /// every version-2 upgrade and overwrite the operator's edits.
313    #[serde(default)]
314    pub integration_blocks: Vec<IntegrationBlock>,
315}
316
317/// One version-1 managed entry: only what the conflict scan reads.
318#[derive(Debug, Clone, Deserialize)]
319pub struct LegacyOwnedFile {
320    /// Where the instance holds the file, relative to its root.
321    pub destination: Utf8PathBuf,
322    /// The bytes version 1 recorded for it.
323    pub sha256: crate::domain::ownership::Sha256,
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::domain::ownership::Sha256;
330
331    fn sample() -> Manifest {
332        Manifest {
333            schema_version: SCHEMA_VERSION,
334            canon_version: "0.2.0".parse().unwrap(),
335            canon_source: CANON_SOURCE.to_string(),
336            profile: ProfileId::KnowledgeBase,
337            docs_root: DocsRoot::UnderscoreDocs,
338            installed_at: "2026-08-25T00:00:00Z".to_string(),
339            plan_zone: PlanZone::Tracked {
340                path: "tests/fixtures".into(),
341            },
342            docs_scratch: Some("scratch".into()),
343            managed_files: vec![ManagedEntry {
344                source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
345                destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
346                sha256: Sha256::of(b"x"),
347            }],
348            adopted_files: vec![],
349            integration_blocks: vec![],
350        }
351    }
352
353    #[test]
354    fn round_trips_through_json() {
355        let manifest = sample();
356        let json = manifest.to_json();
357        assert!(json.ends_with('\n'));
358        assert_eq!(Manifest::parse(&json).unwrap(), manifest);
359    }
360
361    #[test]
362    fn rejects_an_older_schema_as_upgradable() {
363        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
364        value["schema_version"] = 1.into();
365        assert!(matches!(
366            Manifest::parse(&value.to_string()),
367            Err(ManifestParseError::Older(1))
368        ));
369    }
370
371    #[test]
372    fn rejects_a_newer_schema_as_binary_too_old() {
373        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
374        value["schema_version"] = 4.into();
375        assert!(matches!(
376            Manifest::parse(&value.to_string()),
377            Err(ManifestParseError::Newer(4))
378        ));
379    }
380
381    /// A record written before the two locations were declared reads as the
382    /// project declaring neither, rather than as a broken manifest.
383    #[test]
384    fn a_record_without_the_declared_locations_defaults_them() {
385        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
386        value.as_object_mut().unwrap().remove("plan_zone");
387        value.as_object_mut().unwrap().remove("docs_scratch");
388        let manifest = Manifest::parse(&value.to_string()).unwrap();
389        assert_eq!(manifest.plan_zone, PlanZone::None);
390        assert_eq!(manifest.docs_scratch, None);
391    }
392
393    #[test]
394    fn the_plan_zone_round_trips_through_its_tagged_form() {
395        let manifest = sample();
396        let json = manifest.to_json();
397        assert!(json.contains("\"kind\": \"tracked\""));
398        assert_eq!(Manifest::parse(&json).unwrap(), manifest);
399
400        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
401        value["plan_zone"] = serde_json::json!({"kind": "none"});
402        assert_eq!(
403            Manifest::parse(&value.to_string()).unwrap().plan_zone,
404            PlanZone::None
405        );
406    }
407
408    #[test]
409    fn the_plan_zone_argument_takes_four_forms() {
410        assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
411        assert_eq!(PlanZone::parse("env").unwrap(), PlanZone::Env);
412        assert_eq!(
413            PlanZone::parse("docs/plan").unwrap(),
414            PlanZone::Tracked {
415                path: "docs/plan".into()
416            }
417        );
418        assert_eq!(
419            PlanZone::parse("untracked:docs/plan").unwrap(),
420            PlanZone::Untracked {
421                path: "docs/plan".into()
422            }
423        );
424        // A directory carrying one of the two words is written as a path.
425        assert_eq!(
426            PlanZone::parse("./none").unwrap(),
427            PlanZone::Tracked {
428                path: "none".into()
429            }
430        );
431    }
432
433    #[test]
434    fn a_plan_zone_never_leaves_the_repository_and_a_docs_scratch_may() {
435        assert!(PlanZone::parse("/etc/plan").is_err());
436        assert!(PlanZone::parse("../plan").is_err());
437        assert!(PlanZone::parse("untracked:../plan").is_err());
438        assert!(PlanZone::parse("  ").is_err());
439
440        assert_eq!(
441            parse_docs_scratch("../beside-the-checkout").unwrap(),
442            Some(Utf8PathBuf::from("../beside-the-checkout"))
443        );
444        assert!(parse_docs_scratch("/tmp/scratch").is_err());
445        assert!(parse_docs_scratch("").is_err());
446    }
447
448    /// The symmetric prefix is a usage mistake, never a directory name.
449    #[test]
450    fn the_tracked_prefix_is_refused_rather_than_absorbed() {
451        let error = PlanZone::parse("tracked:docs/plan").unwrap_err();
452        assert!(error.to_string().contains("bare path"), "{error}");
453    }
454
455    /// Each declared value has a clearing word, so a typo can be undone.
456    #[test]
457    fn each_declared_location_can_be_cleared() {
458        assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
459        assert_eq!(parse_docs_scratch("none").unwrap(), None);
460        // And a directory literally named `none` is still reachable.
461        assert_eq!(
462            parse_docs_scratch("./none").unwrap(),
463            Some(Utf8PathBuf::from("none"))
464        );
465    }
466
467    /// The record carries the same invariants the arguments enforce. A
468    /// hand-edited empty path would otherwise make the typed-clause gate
469    /// skip a zone the project declared tracked.
470    #[test]
471    fn a_recorded_location_the_arguments_would_refuse_is_invalid() {
472        for zone in [
473            serde_json::json!({"kind": "tracked", "path": ""}),
474            serde_json::json!({"kind": "tracked", "path": "/etc"}),
475            serde_json::json!({"kind": "untracked", "path": "../plan"}),
476        ] {
477            let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
478            value["plan_zone"] = zone.clone();
479            assert!(
480                matches!(
481                    Manifest::parse(&value.to_string()),
482                    Err(ManifestParseError::Invalid(_))
483                ),
484                "{zone} was accepted"
485            );
486        }
487
488        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
489        value["docs_scratch"] = "/tmp/scratch".into();
490        assert!(matches!(
491            Manifest::parse(&value.to_string()),
492            Err(ManifestParseError::Invalid(_))
493        ));
494    }
495
496    #[test]
497    fn rejects_unknown_fields_and_empty_managed_sets() {
498        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
499        value["canon_ref"] = "v0.2.0".into();
500        assert!(matches!(
501            Manifest::parse(&value.to_string()),
502            Err(ManifestParseError::Invalid(_))
503        ));
504
505        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
506        value["managed_files"] = serde_json::Value::Array(vec![]);
507        assert!(matches!(
508            Manifest::parse(&value.to_string()),
509            Err(ManifestParseError::Invalid(_))
510        ));
511    }
512
513    #[test]
514    fn legacy_manifest_reads_a_version_one_shape() {
515        let json = r#"{
516            "schema_version": 1,
517            "canon_version": "0.1.6",
518            "canon_source": "https://github.com/gubasso/spec-driven-docs",
519            "canon_ref": "pre-release",
520            "profile": "knowledge-base",
521            "docs_root": "_docs",
522            "installed_at": "2026-08-24T00:00:00Z",
523            "managed_files": [
524                {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
525                 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
526            ],
527            "adopted_files": [],
528            "integration_blocks": []
529        }"#;
530        let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
531        assert_eq!(legacy.schema_version, 1);
532        assert_eq!(legacy.canon_version.to_string(), "0.1.6");
533        assert_eq!(legacy.managed_files.len(), 1);
534        assert!(legacy.integration_blocks.is_empty());
535    }
536
537    /// A record of the shape version 2 actually wrote — the current one
538    /// without the two declared locations, which version 2 had no field for
539    /// — parses, and its integration blocks reach the upgrade. Without them
540    /// the edited-block conflict check is skipped on every version-2 hop.
541    #[test]
542    fn legacy_manifest_reads_the_version_two_shape() {
543        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
544        value["schema_version"] = 2.into();
545        let object = value.as_object_mut().unwrap();
546        object.remove("plan_zone");
547        object.remove("docs_scratch");
548        value["integration_blocks"] = serde_json::json!([{
549            "path": ".pre-commit-config.yaml",
550            "marker_hash": Sha256::of(b"block").to_string(),
551        }]);
552        let legacy: LegacyManifest = serde_json::from_str(&value.to_string()).unwrap();
553        assert_eq!(legacy.integration_blocks.len(), 1);
554        assert_eq!(legacy.integration_blocks[0].path, ".pre-commit-config.yaml");
555    }
556}