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 Ok(manifest)
103 }
104
105 #[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#[derive(Debug, Clone, Deserialize)]
119pub struct LegacyManifest {
120 pub schema_version: u32,
122 pub canon_version: CanonVersion,
124 pub profile: ProfileId,
126 pub docs_root: DocsRoot,
128 pub installed_at: String,
130 pub managed_files: Vec<LegacyOwnedFile>,
132}
133
134#[derive(Debug, Clone, Deserialize)]
136pub struct LegacyOwnedFile {
137 pub destination: Utf8PathBuf,
139 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}