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 const INSTANCE_DIR: &str = ".spec-driven-docs";
23pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
25
26pub const PLAN_ZONE_VAR: &str = "SDD_PLAN_ZONE";
28pub const DOCS_SCRATCH_VAR: &str = "SDD_DOCS_SCRATCH";
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
40pub enum PlanZone {
41 Tracked {
43 path: Utf8PathBuf,
45 },
46 Untracked {
48 path: Utf8PathBuf,
50 },
51 Env,
53 #[default]
55 None,
56}
57
58#[derive(Debug, Error, PartialEq, Eq)]
60#[error("{0}")]
61pub struct DeclaredPathError(String);
62
63fn declared_path(value: &str, parents: bool) -> Result<Utf8PathBuf, DeclaredPathError> {
69 let value = value.trim();
70 if value.is_empty() {
71 return Err(DeclaredPathError("the path is empty".to_string()));
72 }
73 let path = Utf8Path::new(value);
74 if path.is_absolute() {
75 return Err(DeclaredPathError(format!("{value} is not relative")));
76 }
77 let mut normalized = Utf8PathBuf::new();
78 for component in path.components() {
79 match component {
80 camino::Utf8Component::CurDir => {}
81 camino::Utf8Component::ParentDir if parents => normalized.push(".."),
82 camino::Utf8Component::ParentDir => {
83 return Err(DeclaredPathError(format!("{value} leaves the repository")));
84 }
85 other => normalized.push(other.as_str()),
86 }
87 }
88 if normalized.as_str().is_empty() {
89 return Err(DeclaredPathError(format!("{value} names no directory")));
90 }
91 Ok(normalized)
92}
93
94impl PlanZone {
95 pub fn parse(value: &str) -> Result<Self, DeclaredPathError> {
105 match value.trim() {
106 "none" => Ok(Self::None),
107 "env" => Ok(Self::Env),
108 rest if rest.starts_with("tracked:") => Err(DeclaredPathError(
111 "a tracked zone is written as the bare path; `untracked:` is the only prefix"
112 .to_string(),
113 )),
114 rest => match rest.strip_prefix("untracked:") {
115 Some(path) => Ok(Self::Untracked {
116 path: declared_path(path, false)?,
117 }),
118 None => Ok(Self::Tracked {
119 path: declared_path(rest, false)?,
120 }),
121 },
122 }
123 }
124
125 #[must_use]
127 pub const fn path(&self) -> Option<&Utf8PathBuf> {
128 match self {
129 Self::Tracked { path } | Self::Untracked { path } => Some(path),
130 Self::Env | Self::None => None,
131 }
132 }
133}
134
135pub fn validate_plan_zone_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
146 declared_path(path.as_str(), false).map(|_| ())
147}
148
149pub fn validate_docs_scratch_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
155 declared_path(path.as_str(), true).map(|_| ())
156}
157
158pub fn parse_docs_scratch(value: &str) -> Result<Option<Utf8PathBuf>, DeclaredPathError> {
168 if value.trim() == "none" {
169 return Ok(None);
170 }
171 declared_path(value, true).map(Some)
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct Manifest {
178 pub schema_version: u32,
180 pub canon_version: CanonVersion,
182 pub canon_source: String,
184 pub profile: ProfileId,
186 pub docs_root: DocsRoot,
188 pub installed_at: String,
190 #[serde(default)]
192 pub plan_zone: PlanZone,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub docs_scratch: Option<Utf8PathBuf>,
196 pub managed_files: Vec<ManagedEntry>,
198 pub adopted_files: Vec<AdoptedEntry>,
200 pub integration_blocks: Vec<IntegrationBlock>,
202}
203
204#[derive(Debug, Error)]
206pub enum ManifestParseError {
207 #[error("invalid manifest schema: {0}")]
209 Invalid(String),
210 #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
212 Older(u32),
213 #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
215 Newer(u32),
216}
217
218impl Manifest {
219 pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
228 let value: serde_json::Value =
229 serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
230 match value
231 .get("schema_version")
232 .and_then(serde_json::Value::as_u64)
233 {
234 Some(v) if v == u64::from(SCHEMA_VERSION) => {}
235 Some(v) if v < u64::from(SCHEMA_VERSION) => {
236 return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
237 }
238 Some(v) => {
239 return Err(ManifestParseError::Newer(
240 u32::try_from(v).unwrap_or(u32::MAX),
241 ));
242 }
243 None => {
244 return Err(ManifestParseError::Invalid(
245 "no numeric schema_version".to_string(),
246 ));
247 }
248 }
249 let manifest: Self = serde_json::from_value(value)
250 .map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
251 if manifest.managed_files.is_empty() {
252 return Err(ManifestParseError::Invalid(
253 "managed_files is empty".to_string(),
254 ));
255 }
256 if let Some(path) = manifest.plan_zone.path()
262 && let Err(error) = validate_plan_zone_path(path)
263 {
264 return Err(ManifestParseError::Invalid(format!("plan_zone: {error}")));
265 }
266 if let Some(path) = &manifest.docs_scratch
267 && let Err(error) = validate_docs_scratch_path(path)
268 {
269 return Err(ManifestParseError::Invalid(format!(
270 "docs_scratch: {error}"
271 )));
272 }
273 let mut paths = std::collections::BTreeSet::new();
274 for block in &manifest.integration_blocks {
275 if !paths.insert(&block.path) {
276 return Err(ManifestParseError::Invalid(format!(
277 "duplicate integration block path: {}",
278 block.path
279 )));
280 }
281 }
282 Ok(manifest)
283 }
284
285 #[must_use]
287 pub fn to_json(&self) -> String {
288 let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
289 json.push('\n');
290 json
291 }
292}
293
294#[derive(Debug, Clone, Deserialize)]
300pub struct LegacyManifest {
301 pub schema_version: u32,
303 pub canon_version: CanonVersion,
305 pub profile: ProfileId,
307 pub docs_root: DocsRoot,
309 pub installed_at: String,
311 pub managed_files: Vec<LegacyOwnedFile>,
313 #[serde(default)]
319 pub integration_blocks: Vec<IntegrationBlock>,
320}
321
322#[derive(Debug, Clone, Deserialize)]
324pub struct LegacyOwnedFile {
325 pub destination: Utf8PathBuf,
327 pub sha256: crate::domain::ownership::Sha256,
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::domain::ownership::Sha256;
335
336 fn sample() -> Manifest {
337 Manifest {
338 schema_version: SCHEMA_VERSION,
339 canon_version: "0.2.0".parse().unwrap(),
340 canon_source: CANON_SOURCE.to_string(),
341 profile: ProfileId::KnowledgeBase,
342 docs_root: DocsRoot::UnderscoreDocs,
343 installed_at: "2026-08-25T00:00:00Z".to_string(),
344 plan_zone: PlanZone::Tracked {
345 path: "tests/fixtures".into(),
346 },
347 docs_scratch: Some("scratch".into()),
348 managed_files: vec![ManagedEntry {
349 source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
350 destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
351 sha256: Sha256::of(b"x"),
352 }],
353 adopted_files: vec![],
354 integration_blocks: vec![],
355 }
356 }
357
358 #[test]
359 fn round_trips_through_json() {
360 let manifest = sample();
361 let json = manifest.to_json();
362 assert!(json.ends_with('\n'));
363 assert_eq!(Manifest::parse(&json).unwrap(), manifest);
364 }
365
366 #[test]
367 fn rejects_an_older_schema_as_upgradable() {
368 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
369 value["schema_version"] = 1.into();
370 assert!(matches!(
371 Manifest::parse(&value.to_string()),
372 Err(ManifestParseError::Older(1))
373 ));
374 }
375
376 #[test]
377 fn rejects_a_newer_schema_as_binary_too_old() {
378 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
379 value["schema_version"] = 4.into();
380 assert!(matches!(
381 Manifest::parse(&value.to_string()),
382 Err(ManifestParseError::Newer(4))
383 ));
384 }
385
386 #[test]
389 fn a_record_without_the_declared_locations_defaults_them() {
390 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
391 value.as_object_mut().unwrap().remove("plan_zone");
392 value.as_object_mut().unwrap().remove("docs_scratch");
393 let manifest = Manifest::parse(&value.to_string()).unwrap();
394 assert_eq!(manifest.plan_zone, PlanZone::None);
395 assert_eq!(manifest.docs_scratch, None);
396 }
397
398 #[test]
399 fn the_plan_zone_round_trips_through_its_tagged_form() {
400 let manifest = sample();
401 let json = manifest.to_json();
402 assert!(json.contains("\"kind\": \"tracked\""));
403 assert_eq!(Manifest::parse(&json).unwrap(), manifest);
404
405 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
406 value["plan_zone"] = serde_json::json!({"kind": "none"});
407 assert_eq!(
408 Manifest::parse(&value.to_string()).unwrap().plan_zone,
409 PlanZone::None
410 );
411 }
412
413 #[test]
414 fn the_plan_zone_argument_takes_four_forms() {
415 assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
416 assert_eq!(PlanZone::parse("env").unwrap(), PlanZone::Env);
417 assert_eq!(
418 PlanZone::parse("docs/plan").unwrap(),
419 PlanZone::Tracked {
420 path: "docs/plan".into()
421 }
422 );
423 assert_eq!(
424 PlanZone::parse("untracked:docs/plan").unwrap(),
425 PlanZone::Untracked {
426 path: "docs/plan".into()
427 }
428 );
429 assert_eq!(
431 PlanZone::parse("./none").unwrap(),
432 PlanZone::Tracked {
433 path: "none".into()
434 }
435 );
436 }
437
438 #[test]
439 fn a_plan_zone_never_leaves_the_repository_and_a_docs_scratch_may() {
440 assert!(PlanZone::parse("/etc/plan").is_err());
441 assert!(PlanZone::parse("../plan").is_err());
442 assert!(PlanZone::parse("untracked:../plan").is_err());
443 assert!(PlanZone::parse(" ").is_err());
444
445 assert_eq!(
446 parse_docs_scratch("../beside-the-checkout").unwrap(),
447 Some(Utf8PathBuf::from("../beside-the-checkout"))
448 );
449 assert!(parse_docs_scratch("/tmp/scratch").is_err());
450 assert!(parse_docs_scratch("").is_err());
451 }
452
453 #[test]
455 fn the_tracked_prefix_is_refused_rather_than_absorbed() {
456 let error = PlanZone::parse("tracked:docs/plan").unwrap_err();
457 assert!(error.to_string().contains("bare path"), "{error}");
458 }
459
460 #[test]
462 fn each_declared_location_can_be_cleared() {
463 assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
464 assert_eq!(parse_docs_scratch("none").unwrap(), None);
465 assert_eq!(
467 parse_docs_scratch("./none").unwrap(),
468 Some(Utf8PathBuf::from("none"))
469 );
470 }
471
472 #[test]
476 fn a_recorded_location_the_arguments_would_refuse_is_invalid() {
477 for zone in [
478 serde_json::json!({"kind": "tracked", "path": ""}),
479 serde_json::json!({"kind": "tracked", "path": "/etc"}),
480 serde_json::json!({"kind": "untracked", "path": "../plan"}),
481 ] {
482 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
483 value["plan_zone"] = zone.clone();
484 assert!(
485 matches!(
486 Manifest::parse(&value.to_string()),
487 Err(ManifestParseError::Invalid(_))
488 ),
489 "{zone} was accepted"
490 );
491 }
492
493 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
494 value["docs_scratch"] = "/tmp/scratch".into();
495 assert!(matches!(
496 Manifest::parse(&value.to_string()),
497 Err(ManifestParseError::Invalid(_))
498 ));
499 }
500
501 #[test]
502 fn rejects_unknown_fields_and_empty_managed_sets() {
503 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
504 value["canon_ref"] = "v0.2.0".into();
505 assert!(matches!(
506 Manifest::parse(&value.to_string()),
507 Err(ManifestParseError::Invalid(_))
508 ));
509
510 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
511 value["managed_files"] = serde_json::Value::Array(vec![]);
512 assert!(matches!(
513 Manifest::parse(&value.to_string()),
514 Err(ManifestParseError::Invalid(_))
515 ));
516 }
517
518 #[test]
519 fn legacy_manifest_reads_a_version_one_shape() {
520 let json = r#"{
521 "schema_version": 1,
522 "canon_version": "0.1.6",
523 "canon_source": "https://github.com/gubasso/spec-driven-docs",
524 "canon_ref": "pre-release",
525 "profile": "knowledge-base",
526 "docs_root": "_docs",
527 "installed_at": "2026-08-24T00:00:00Z",
528 "managed_files": [
529 {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
530 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
531 ],
532 "adopted_files": [],
533 "integration_blocks": []
534 }"#;
535 let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
536 assert_eq!(legacy.schema_version, 1);
537 assert_eq!(legacy.canon_version.to_string(), "0.1.6");
538 assert_eq!(legacy.managed_files.len(), 1);
539 assert!(legacy.integration_blocks.is_empty());
540 }
541
542 #[test]
547 fn legacy_manifest_reads_the_version_two_shape() {
548 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
549 value["schema_version"] = 2.into();
550 let object = value.as_object_mut().unwrap();
551 object.remove("plan_zone");
552 object.remove("docs_scratch");
553 value["integration_blocks"] = serde_json::json!([{
554 "path": ".pre-commit-config.yaml",
555 "marker_hash": Sha256::of(b"block").to_string(),
556 }]);
557 let legacy: LegacyManifest = serde_json::from_str(&value.to_string()).unwrap();
558 assert_eq!(legacy.integration_blocks.len(), 1);
559 assert_eq!(legacy.integration_blocks[0].path, ".pre-commit-config.yaml");
560 }
561}