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        let mut paths = std::collections::BTreeSet::new();
103        for block in &manifest.integration_blocks {
104            if !paths.insert(&block.path) {
105                return Err(ManifestParseError::Invalid(format!(
106                    "duplicate integration block path: {}",
107                    block.path
108                )));
109            }
110        }
111        Ok(manifest)
112    }
113
114    /// Serialize in the canonical on-disk form: two-space indent, trailing newline.
115    #[must_use]
116    pub fn to_json(&self) -> String {
117        let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
118        json.push('\n');
119        json
120    }
121}
122
123/// The parts of a schema-version-1 manifest an upgrade needs.
124///
125/// Deliberately permissive: unknown version-1 fields pass through unread so
126/// the upgrader can migrate any instance the previous distribution produced.
127#[derive(Debug, Clone, Deserialize)]
128pub struct LegacyManifest {
129    /// The recorded schema version; the upgrader requires `1`.
130    pub schema_version: u32,
131    /// The canon release the instance was installed from.
132    pub canon_version: CanonVersion,
133    /// The profile the instance was installed with.
134    pub profile: ProfileId,
135    /// The documentation root the gates read.
136    pub docs_root: DocsRoot,
137    /// When the instance was first installed.
138    pub installed_at: String,
139    /// Destination and hash of every file version 1 managed.
140    pub managed_files: Vec<LegacyOwnedFile>,
141}
142
143/// One version-1 managed entry: only what the conflict scan reads.
144#[derive(Debug, Clone, Deserialize)]
145pub struct LegacyOwnedFile {
146    /// Where the instance holds the file, relative to its root.
147    pub destination: Utf8PathBuf,
148    /// The bytes version 1 recorded for it.
149    pub sha256: crate::domain::ownership::Sha256,
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::domain::ownership::Sha256;
156
157    fn sample() -> Manifest {
158        Manifest {
159            schema_version: SCHEMA_VERSION,
160            canon_version: "0.2.0".parse().unwrap(),
161            canon_source: CANON_SOURCE.to_string(),
162            profile: ProfileId::KnowledgeBase,
163            docs_root: DocsRoot::UnderscoreDocs,
164            installed_at: "2026-08-25T00:00:00Z".to_string(),
165            managed_files: vec![ManagedEntry {
166                source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
167                destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
168                sha256: Sha256::of(b"x"),
169            }],
170            adopted_files: vec![],
171            integration_blocks: vec![],
172        }
173    }
174
175    #[test]
176    fn round_trips_through_json() {
177        let manifest = sample();
178        let json = manifest.to_json();
179        assert!(json.ends_with('\n'));
180        assert_eq!(Manifest::parse(&json).unwrap(), manifest);
181    }
182
183    #[test]
184    fn rejects_an_older_schema_as_upgradable() {
185        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
186        value["schema_version"] = 1.into();
187        assert!(matches!(
188            Manifest::parse(&value.to_string()),
189            Err(ManifestParseError::Older(1))
190        ));
191    }
192
193    #[test]
194    fn rejects_a_newer_schema_as_binary_too_old() {
195        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
196        value["schema_version"] = 3.into();
197        assert!(matches!(
198            Manifest::parse(&value.to_string()),
199            Err(ManifestParseError::Newer(3))
200        ));
201    }
202
203    #[test]
204    fn rejects_unknown_fields_and_empty_managed_sets() {
205        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
206        value["canon_ref"] = "v0.2.0".into();
207        assert!(matches!(
208            Manifest::parse(&value.to_string()),
209            Err(ManifestParseError::Invalid(_))
210        ));
211
212        let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
213        value["managed_files"] = serde_json::Value::Array(vec![]);
214        assert!(matches!(
215            Manifest::parse(&value.to_string()),
216            Err(ManifestParseError::Invalid(_))
217        ));
218    }
219
220    #[test]
221    fn legacy_manifest_reads_a_version_one_shape() {
222        let json = r#"{
223            "schema_version": 1,
224            "canon_version": "0.1.6",
225            "canon_source": "https://github.com/gubasso/spec-driven-docs",
226            "canon_ref": "pre-release",
227            "profile": "knowledge-base",
228            "docs_root": "_docs",
229            "installed_at": "2026-08-24T00:00:00Z",
230            "managed_files": [
231                {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
232                 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
233            ],
234            "adopted_files": [],
235            "integration_blocks": []
236        }"#;
237        let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
238        assert_eq!(legacy.schema_version, 1);
239        assert_eq!(legacy.canon_version.to_string(), "0.1.6");
240        assert_eq!(legacy.managed_files.len(), 1);
241    }
242}