spec_driven_docs/domain/
manifest.rs1use 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
17pub const SCHEMA_VERSION: u32 = 2;
19pub const CANON_SOURCE: &str = "https://github.com/gubasso/spec-driven-docs";
21pub const INSTANCE_DIR: &str = ".spec-driven-docs";
23pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct Manifest {
30 pub schema_version: u32,
32 pub canon_version: CanonVersion,
34 pub canon_source: String,
36 pub profile: ProfileId,
38 pub docs_root: DocsRoot,
40 pub installed_at: String,
42 pub managed_files: Vec<ManagedEntry>,
44 pub adopted_files: Vec<AdoptedEntry>,
46 pub integration_blocks: Vec<IntegrationBlock>,
48}
49
50#[derive(Debug, Error)]
52pub enum ManifestParseError {
53 #[error("invalid manifest schema: {0}")]
55 Invalid(String),
56 #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
58 Older(u32),
59 #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
61 Newer(u32),
62}
63
64impl Manifest {
65 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 #[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#[derive(Debug, Clone, Deserialize)]
128pub struct LegacyManifest {
129 pub schema_version: u32,
131 pub canon_version: CanonVersion,
133 pub profile: ProfileId,
135 pub docs_root: DocsRoot,
137 pub installed_at: String,
139 pub managed_files: Vec<LegacyOwnedFile>,
141}
142
143#[derive(Debug, Clone, Deserialize)]
145pub struct LegacyOwnedFile {
146 pub destination: Utf8PathBuf,
148 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}