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