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 2. 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::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 = 2;
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/// Everything an instance records about itself.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct Manifest {
30    /// Always [`SCHEMA_VERSION`] once parsed.
31    pub schema_version: u32,
32    /// The canon release that produced the installed payload.
33    pub canon_version: CanonVersion,
34    /// Where that canon is published.
35    pub canon_source: String,
36    /// The profile the instance was installed with.
37    pub profile: ProfileId,
38    /// The documentation root the gates read.
39    pub docs_root: DocsRoot,
40    /// When the instance was first installed; preserved across reinstalls.
41    pub installed_at: String,
42    /// Byte projections the canon keeps owning.
43    pub managed_files: Vec<ManagedEntry>,
44    /// Files the instance owns against a recorded baseline.
45    pub adopted_files: Vec<AdoptedEntry>,
46    /// Marked regions the canon owns inside project files.
47    pub integration_blocks: Vec<IntegrationBlock>,
48}
49
50/// A manifest that could not be accepted.
51#[derive(Debug, Error)]
52pub enum ManifestParseError {
53    /// Not JSON, or JSON that does not fit the schema.
54    #[error("invalid manifest schema: {0}")]
55    Invalid(String),
56    /// A well-formed manifest of an older schema; upgradable, not readable.
57    #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
58    Older(u32),
59    /// A well-formed manifest of a newer schema; this binary is too old.
60    #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
61    Newer(u32),
62}
63
64impl Manifest {
65    /// Parse and validate a serialized manifest.
66    ///
67    /// # Errors
68    ///
69    /// [`ManifestParseError::Older`] / [`ManifestParseError::Newer`] when the
70    /// recorded schema version is not [`SCHEMA_VERSION`], and
71    /// [`ManifestParseError::Invalid`] for anything that does not fit the
72    /// schema or records no managed file.
73    pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
74        let value: serde_json::Value =
75            serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
76        match value
77            .get("schema_version")
78            .and_then(serde_json::Value::as_u64)
79        {
80            Some(v) if v == u64::from(SCHEMA_VERSION) => {}
81            Some(v) if v < u64::from(SCHEMA_VERSION) => {
82                return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
83            }
84            Some(v) => {
85                return Err(ManifestParseError::Newer(
86                    u32::try_from(v).unwrap_or(u32::MAX),
87                ));
88            }
89            None => {
90                return Err(ManifestParseError::Invalid(
91                    "no numeric schema_version".to_string(),
92                ));
93            }
94        }
95        let manifest: Self = serde_json::from_value(value)
96            .map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
97        if manifest.managed_files.is_empty() {
98            return Err(ManifestParseError::Invalid(
99                "managed_files is empty".to_string(),
100            ));
101        }
102        Ok(manifest)
103    }
104
105    /// Serialize in the canonical on-disk form: two-space indent, trailing newline.
106    #[must_use]
107    pub fn to_json(&self) -> String {
108        let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
109        json.push('\n');
110        json
111    }
112}
113
114/// The parts of a schema-version-1 manifest an upgrade needs.
115///
116/// Deliberately permissive: unknown version-1 fields pass through unread so
117/// the upgrader can migrate any instance the previous distribution produced.
118#[derive(Debug, Clone, Deserialize)]
119pub struct LegacyManifest {
120    /// The recorded schema version; the upgrader requires `1`.
121    pub schema_version: u32,
122    /// The canon release the instance was installed from.
123    pub canon_version: CanonVersion,
124    /// The profile the instance was installed with.
125    pub profile: ProfileId,
126    /// The documentation root the gates read.
127    pub docs_root: DocsRoot,
128    /// When the instance was first installed.
129    pub installed_at: String,
130    /// Destination and hash of every file version 1 managed.
131    pub managed_files: Vec<LegacyOwnedFile>,
132}
133
134/// One version-1 managed entry: only what the conflict scan reads.
135#[derive(Debug, Clone, Deserialize)]
136pub struct LegacyOwnedFile {
137    /// Where the instance holds the file, relative to its root.
138    pub destination: Utf8PathBuf,
139    /// The bytes version 1 recorded for it.
140    pub sha256: crate::domain::ownership::Sha256,
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::domain::ownership::Sha256;
147
148    fn sample() -> Manifest {
149        Manifest {
150            schema_version: SCHEMA_VERSION,
151            canon_version: "0.2.0".parse().unwrap(),
152            canon_source: CANON_SOURCE.to_string(),
153            profile: ProfileId::KnowledgeBase,
154            docs_root: DocsRoot::UnderscoreDocs,
155            installed_at: "2026-08-25T00:00:00Z".to_string(),
156            managed_files: vec![ManagedEntry {
157                source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
158                destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
159                sha256: Sha256::of(b"x"),
160            }],
161            adopted_files: vec![],
162            integration_blocks: vec![],
163        }
164    }
165
166    #[test]
167    fn round_trips_through_json() {
168        let manifest = sample();
169        let json = manifest.to_json();
170        assert!(json.ends_with('\n'));
171        assert_eq!(Manifest::parse(&json).unwrap(), manifest);
172    }
173
174    #[test]
175    fn rejects_an_older_schema_as_upgradable() {
176        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
177        value["schema_version"] = 1.into();
178        assert!(matches!(
179            Manifest::parse(&value.to_string()),
180            Err(ManifestParseError::Older(1))
181        ));
182    }
183
184    #[test]
185    fn rejects_a_newer_schema_as_binary_too_old() {
186        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
187        value["schema_version"] = 3.into();
188        assert!(matches!(
189            Manifest::parse(&value.to_string()),
190            Err(ManifestParseError::Newer(3))
191        ));
192    }
193
194    #[test]
195    fn rejects_unknown_fields_and_empty_managed_sets() {
196        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
197        value["canon_ref"] = "v0.2.0".into();
198        assert!(matches!(
199            Manifest::parse(&value.to_string()),
200            Err(ManifestParseError::Invalid(_))
201        ));
202
203        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
204        value["managed_files"] = serde_json::Value::Array(vec![]);
205        assert!(matches!(
206            Manifest::parse(&value.to_string()),
207            Err(ManifestParseError::Invalid(_))
208        ));
209    }
210
211    #[test]
212    fn legacy_manifest_reads_a_version_one_shape() {
213        let json = r#"{
214            "schema_version": 1,
215            "canon_version": "0.1.6",
216            "canon_source": "https://github.com/gubasso/spec-driven-docs",
217            "canon_ref": "pre-release",
218            "profile": "knowledge-base",
219            "docs_root": "_docs",
220            "installed_at": "2026-08-24T00:00:00Z",
221            "managed_files": [
222                {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
223                 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
224            ],
225            "adopted_files": [],
226            "integration_blocks": []
227        }"#;
228        let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
229        assert_eq!(legacy.schema_version, 1);
230        assert_eq!(legacy.canon_version.to_string(), "0.1.6");
231        assert_eq!(legacy.managed_files.len(), 1);
232    }
233}