1use 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
17pub const SCHEMA_VERSION: u32 = 3;
19pub const CANON_SOURCE: &str = "https://github.com/gubasso/spec-driven-docs";
21pub use crate::domain::paths::{DOCS_SCRATCH_VAR, INSTANCE_DIR, MANIFEST_PATH};
25
26pub const RETIRED_FIELDS: &[&str] = &["plan_zone"];
33
34#[derive(Debug, Error, PartialEq, Eq)]
36#[error("{0}")]
37pub struct DeclaredPathError(String);
38
39fn declared_path(value: &str, parents: bool) -> Result<Utf8PathBuf, DeclaredPathError> {
45 let value = value.trim();
46 if value.is_empty() {
47 return Err(DeclaredPathError("the path is empty".to_string()));
48 }
49 let path = Utf8Path::new(value);
50 if path.is_absolute() {
51 return Err(DeclaredPathError(format!("{value} is not relative")));
52 }
53 let mut normalized = Utf8PathBuf::new();
54 for component in path.components() {
55 match component {
56 camino::Utf8Component::CurDir => {}
57 camino::Utf8Component::ParentDir if parents => normalized.push(".."),
58 camino::Utf8Component::ParentDir => {
59 return Err(DeclaredPathError(format!("{value} leaves the repository")));
60 }
61 other => normalized.push(other.as_str()),
62 }
63 }
64 if normalized.as_str().is_empty() {
65 return Err(DeclaredPathError(format!("{value} names no directory")));
66 }
67 Ok(normalized)
68}
69
70pub fn validate_docs_scratch_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
80 declared_path(path.as_str(), true).map(|_| ())
81}
82
83pub fn parse_docs_scratch(value: &str) -> Result<Option<Utf8PathBuf>, DeclaredPathError> {
93 if value.trim() == "none" {
94 return Ok(None);
95 }
96 declared_path(value, true).map(Some)
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(deny_unknown_fields)]
102pub struct Manifest {
103 pub schema_version: u32,
105 pub canon_version: CanonVersion,
107 pub canon_source: String,
109 pub profile: ProfileId,
111 pub docs_root: DocsRoot,
113 pub installed_at: String,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub docs_scratch: Option<Utf8PathBuf>,
118 pub managed_files: Vec<ManagedEntry>,
120 pub adopted_files: Vec<AdoptedEntry>,
122 pub integration_blocks: Vec<IntegrationBlock>,
124}
125
126#[derive(Debug, Error)]
128pub enum ManifestParseError {
129 #[error("invalid manifest schema: {0}")]
131 Invalid(String),
132 #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
134 Older(u32),
135 #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
137 Newer(u32),
138}
139
140impl Manifest {
141 pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
150 let mut value: serde_json::Value =
151 serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
152 match value
153 .get("schema_version")
154 .and_then(serde_json::Value::as_u64)
155 {
156 Some(v) if v == u64::from(SCHEMA_VERSION) => {}
157 Some(v) if v < u64::from(SCHEMA_VERSION) => {
158 return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
159 }
160 Some(v) => {
161 return Err(ManifestParseError::Newer(
162 u32::try_from(v).unwrap_or(u32::MAX),
163 ));
164 }
165 None => {
166 return Err(ManifestParseError::Invalid(
167 "no numeric schema_version".to_string(),
168 ));
169 }
170 }
171 if let Some(object) = value.as_object_mut() {
174 for field in RETIRED_FIELDS {
175 object.remove(*field);
176 }
177 }
178 let manifest: Self = serde_json::from_value(value)
179 .map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
180 if manifest.managed_files.is_empty() {
181 return Err(ManifestParseError::Invalid(
182 "managed_files is empty".to_string(),
183 ));
184 }
185 if let Some(path) = &manifest.docs_scratch
190 && let Err(error) = validate_docs_scratch_path(path)
191 {
192 return Err(ManifestParseError::Invalid(format!(
193 "docs_scratch: {error}"
194 )));
195 }
196 let mut paths = std::collections::BTreeSet::new();
197 for block in &manifest.integration_blocks {
198 if !paths.insert(&block.path) {
199 return Err(ManifestParseError::Invalid(format!(
200 "duplicate integration block path: {}",
201 block.path
202 )));
203 }
204 }
205 Ok(manifest)
206 }
207
208 #[must_use]
210 pub fn to_json(&self) -> String {
211 let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
212 json.push('\n');
213 json
214 }
215}
216
217#[derive(Debug, Clone, Deserialize)]
223pub struct LegacyManifest {
224 pub schema_version: u32,
226 pub canon_version: CanonVersion,
228 pub profile: ProfileId,
230 pub docs_root: DocsRoot,
232 pub installed_at: String,
234 pub managed_files: Vec<LegacyOwnedFile>,
236 #[serde(default)]
242 pub integration_blocks: Vec<IntegrationBlock>,
243}
244
245#[derive(Debug, Clone, Deserialize)]
247pub struct LegacyOwnedFile {
248 pub destination: Utf8PathBuf,
250 pub sha256: crate::domain::ownership::Sha256,
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use crate::domain::ownership::Sha256;
258
259 fn sample() -> Manifest {
260 Manifest {
261 schema_version: SCHEMA_VERSION,
262 canon_version: "0.2.0".parse().unwrap(),
263 canon_source: CANON_SOURCE.to_string(),
264 profile: ProfileId::KnowledgeBase,
265 docs_root: DocsRoot::UnderscoreDocs,
266 installed_at: "2026-08-25T00:00:00Z".to_string(),
267 docs_scratch: Some("scratch".into()),
268 managed_files: vec![ManagedEntry {
269 source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
270 destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
271 sha256: Sha256::of(b"x"),
272 }],
273 adopted_files: vec![],
274 integration_blocks: vec![],
275 }
276 }
277
278 #[test]
279 fn round_trips_through_json() {
280 let manifest = sample();
281 let json = manifest.to_json();
282 assert!(json.ends_with('\n'));
283 assert_eq!(Manifest::parse(&json).unwrap(), manifest);
284 }
285
286 #[test]
287 fn rejects_an_older_schema_as_upgradable() {
288 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
289 value["schema_version"] = 1.into();
290 assert!(matches!(
291 Manifest::parse(&value.to_string()),
292 Err(ManifestParseError::Older(1))
293 ));
294 }
295
296 #[test]
297 fn rejects_a_newer_schema_as_binary_too_old() {
298 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
299 value["schema_version"] = 4.into();
300 assert!(matches!(
301 Manifest::parse(&value.to_string()),
302 Err(ManifestParseError::Newer(4))
303 ));
304 }
305
306 #[test]
309 fn a_record_without_the_declared_location_defaults_it() {
310 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
311 value.as_object_mut().unwrap().remove("docs_scratch");
312 let manifest = Manifest::parse(&value.to_string()).unwrap();
313 assert_eq!(manifest.docs_scratch, None);
314 }
315
316 #[test]
319 fn a_retired_field_is_dropped_on_read_and_omitted_on_write() {
320 for retired in RETIRED_FIELDS {
321 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
322 value[*retired] = serde_json::json!({"kind": "tracked", "path": "docs/plan"});
323 let manifest = Manifest::parse(&value.to_string()).unwrap();
324 assert_eq!(manifest, sample());
325 assert!(!manifest.to_json().contains(retired));
326 }
327 }
328
329 #[test]
330 fn a_docs_scratch_may_leave_the_repository() {
331 assert_eq!(
332 parse_docs_scratch("../beside-the-checkout").unwrap(),
333 Some(Utf8PathBuf::from("../beside-the-checkout"))
334 );
335 assert!(parse_docs_scratch("/tmp/scratch").is_err());
336 assert!(parse_docs_scratch("").is_err());
337 }
338
339 #[test]
341 fn the_declared_location_can_be_cleared() {
342 assert_eq!(parse_docs_scratch("none").unwrap(), None);
343 assert_eq!(
345 parse_docs_scratch("./none").unwrap(),
346 Some(Utf8PathBuf::from("none"))
347 );
348 }
349
350 #[test]
352 fn a_recorded_location_the_argument_would_refuse_is_invalid() {
353 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
354 value["docs_scratch"] = "/tmp/scratch".into();
355 assert!(matches!(
356 Manifest::parse(&value.to_string()),
357 Err(ManifestParseError::Invalid(_))
358 ));
359 }
360
361 #[test]
362 fn rejects_unknown_fields_and_empty_managed_sets() {
363 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
364 value["canon_ref"] = "v0.2.0".into();
365 assert!(matches!(
366 Manifest::parse(&value.to_string()),
367 Err(ManifestParseError::Invalid(_))
368 ));
369
370 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
371 value["managed_files"] = serde_json::Value::Array(vec![]);
372 assert!(matches!(
373 Manifest::parse(&value.to_string()),
374 Err(ManifestParseError::Invalid(_))
375 ));
376 }
377
378 #[test]
379 fn legacy_manifest_reads_a_version_one_shape() {
380 let json = r#"{
381 "schema_version": 1,
382 "canon_version": "0.1.6",
383 "canon_source": "https://github.com/gubasso/spec-driven-docs",
384 "canon_ref": "pre-release",
385 "profile": "knowledge-base",
386 "docs_root": "_docs",
387 "installed_at": "2026-08-24T00:00:00Z",
388 "managed_files": [
389 {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
390 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
391 ],
392 "adopted_files": [],
393 "integration_blocks": []
394 }"#;
395 let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
396 assert_eq!(legacy.schema_version, 1);
397 assert_eq!(legacy.canon_version.to_string(), "0.1.6");
398 assert_eq!(legacy.managed_files.len(), 1);
399 assert!(legacy.integration_blocks.is_empty());
400 }
401
402 #[test]
407 fn legacy_manifest_reads_the_version_two_shape() {
408 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
409 value["schema_version"] = 2.into();
410 let object = value.as_object_mut().unwrap();
411 object.remove("docs_scratch");
412 value["integration_blocks"] = serde_json::json!([{
413 "path": ".pre-commit-config.yaml",
414 "marker_hash": Sha256::of(b"block").to_string(),
415 }]);
416 let legacy: LegacyManifest = serde_json::from_str(&value.to_string()).unwrap();
417 assert_eq!(legacy.integration_blocks.len(), 1);
418 assert_eq!(legacy.integration_blocks[0].path, ".pre-commit-config.yaml");
419 }
420}