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, PLAN_ZONE_VAR};
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
35pub enum PlanZone {
36 Tracked {
38 path: Utf8PathBuf,
40 },
41 Untracked {
43 path: Utf8PathBuf,
45 },
46 Env,
48 #[default]
50 None,
51}
52
53#[derive(Debug, Error, PartialEq, Eq)]
55#[error("{0}")]
56pub struct DeclaredPathError(String);
57
58fn declared_path(value: &str, parents: bool) -> Result<Utf8PathBuf, DeclaredPathError> {
64 let value = value.trim();
65 if value.is_empty() {
66 return Err(DeclaredPathError("the path is empty".to_string()));
67 }
68 let path = Utf8Path::new(value);
69 if path.is_absolute() {
70 return Err(DeclaredPathError(format!("{value} is not relative")));
71 }
72 let mut normalized = Utf8PathBuf::new();
73 for component in path.components() {
74 match component {
75 camino::Utf8Component::CurDir => {}
76 camino::Utf8Component::ParentDir if parents => normalized.push(".."),
77 camino::Utf8Component::ParentDir => {
78 return Err(DeclaredPathError(format!("{value} leaves the repository")));
79 }
80 other => normalized.push(other.as_str()),
81 }
82 }
83 if normalized.as_str().is_empty() {
84 return Err(DeclaredPathError(format!("{value} names no directory")));
85 }
86 Ok(normalized)
87}
88
89impl PlanZone {
90 pub fn parse(value: &str) -> Result<Self, DeclaredPathError> {
100 match value.trim() {
101 "none" => Ok(Self::None),
102 "env" => Ok(Self::Env),
103 rest if rest.starts_with("tracked:") => Err(DeclaredPathError(
106 "a tracked zone is written as the bare path; `untracked:` is the only prefix"
107 .to_string(),
108 )),
109 rest => match rest.strip_prefix("untracked:") {
110 Some(path) => Ok(Self::Untracked {
111 path: declared_path(path, false)?,
112 }),
113 None => Ok(Self::Tracked {
114 path: declared_path(rest, false)?,
115 }),
116 },
117 }
118 }
119
120 #[must_use]
122 pub const fn path(&self) -> Option<&Utf8PathBuf> {
123 match self {
124 Self::Tracked { path } | Self::Untracked { path } => Some(path),
125 Self::Env | Self::None => None,
126 }
127 }
128}
129
130pub fn validate_plan_zone_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
141 declared_path(path.as_str(), false).map(|_| ())
142}
143
144pub fn validate_docs_scratch_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
150 declared_path(path.as_str(), true).map(|_| ())
151}
152
153pub fn parse_docs_scratch(value: &str) -> Result<Option<Utf8PathBuf>, DeclaredPathError> {
163 if value.trim() == "none" {
164 return Ok(None);
165 }
166 declared_path(value, true).map(Some)
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(deny_unknown_fields)]
172pub struct Manifest {
173 pub schema_version: u32,
175 pub canon_version: CanonVersion,
177 pub canon_source: String,
179 pub profile: ProfileId,
181 pub docs_root: DocsRoot,
183 pub installed_at: String,
185 #[serde(default)]
187 pub plan_zone: PlanZone,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub docs_scratch: Option<Utf8PathBuf>,
191 pub managed_files: Vec<ManagedEntry>,
193 pub adopted_files: Vec<AdoptedEntry>,
195 pub integration_blocks: Vec<IntegrationBlock>,
197}
198
199#[derive(Debug, Error)]
201pub enum ManifestParseError {
202 #[error("invalid manifest schema: {0}")]
204 Invalid(String),
205 #[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
207 Older(u32),
208 #[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
210 Newer(u32),
211}
212
213impl Manifest {
214 pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
223 let value: serde_json::Value =
224 serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
225 match value
226 .get("schema_version")
227 .and_then(serde_json::Value::as_u64)
228 {
229 Some(v) if v == u64::from(SCHEMA_VERSION) => {}
230 Some(v) if v < u64::from(SCHEMA_VERSION) => {
231 return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
232 }
233 Some(v) => {
234 return Err(ManifestParseError::Newer(
235 u32::try_from(v).unwrap_or(u32::MAX),
236 ));
237 }
238 None => {
239 return Err(ManifestParseError::Invalid(
240 "no numeric schema_version".to_string(),
241 ));
242 }
243 }
244 let manifest: Self = serde_json::from_value(value)
245 .map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
246 if manifest.managed_files.is_empty() {
247 return Err(ManifestParseError::Invalid(
248 "managed_files is empty".to_string(),
249 ));
250 }
251 if let Some(path) = manifest.plan_zone.path()
257 && let Err(error) = validate_plan_zone_path(path)
258 {
259 return Err(ManifestParseError::Invalid(format!("plan_zone: {error}")));
260 }
261 if let Some(path) = &manifest.docs_scratch
262 && let Err(error) = validate_docs_scratch_path(path)
263 {
264 return Err(ManifestParseError::Invalid(format!(
265 "docs_scratch: {error}"
266 )));
267 }
268 let mut paths = std::collections::BTreeSet::new();
269 for block in &manifest.integration_blocks {
270 if !paths.insert(&block.path) {
271 return Err(ManifestParseError::Invalid(format!(
272 "duplicate integration block path: {}",
273 block.path
274 )));
275 }
276 }
277 Ok(manifest)
278 }
279
280 #[must_use]
282 pub fn to_json(&self) -> String {
283 let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
284 json.push('\n');
285 json
286 }
287}
288
289#[derive(Debug, Clone, Deserialize)]
295pub struct LegacyManifest {
296 pub schema_version: u32,
298 pub canon_version: CanonVersion,
300 pub profile: ProfileId,
302 pub docs_root: DocsRoot,
304 pub installed_at: String,
306 pub managed_files: Vec<LegacyOwnedFile>,
308 #[serde(default)]
314 pub integration_blocks: Vec<IntegrationBlock>,
315}
316
317#[derive(Debug, Clone, Deserialize)]
319pub struct LegacyOwnedFile {
320 pub destination: Utf8PathBuf,
322 pub sha256: crate::domain::ownership::Sha256,
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::domain::ownership::Sha256;
330
331 fn sample() -> Manifest {
332 Manifest {
333 schema_version: SCHEMA_VERSION,
334 canon_version: "0.2.0".parse().unwrap(),
335 canon_source: CANON_SOURCE.to_string(),
336 profile: ProfileId::KnowledgeBase,
337 docs_root: DocsRoot::UnderscoreDocs,
338 installed_at: "2026-08-25T00:00:00Z".to_string(),
339 plan_zone: PlanZone::Tracked {
340 path: "tests/fixtures".into(),
341 },
342 docs_scratch: Some("scratch".into()),
343 managed_files: vec![ManagedEntry {
344 source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
345 destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
346 sha256: Sha256::of(b"x"),
347 }],
348 adopted_files: vec![],
349 integration_blocks: vec![],
350 }
351 }
352
353 #[test]
354 fn round_trips_through_json() {
355 let manifest = sample();
356 let json = manifest.to_json();
357 assert!(json.ends_with('\n'));
358 assert_eq!(Manifest::parse(&json).unwrap(), manifest);
359 }
360
361 #[test]
362 fn rejects_an_older_schema_as_upgradable() {
363 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
364 value["schema_version"] = 1.into();
365 assert!(matches!(
366 Manifest::parse(&value.to_string()),
367 Err(ManifestParseError::Older(1))
368 ));
369 }
370
371 #[test]
372 fn rejects_a_newer_schema_as_binary_too_old() {
373 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
374 value["schema_version"] = 4.into();
375 assert!(matches!(
376 Manifest::parse(&value.to_string()),
377 Err(ManifestParseError::Newer(4))
378 ));
379 }
380
381 #[test]
384 fn a_record_without_the_declared_locations_defaults_them() {
385 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
386 value.as_object_mut().unwrap().remove("plan_zone");
387 value.as_object_mut().unwrap().remove("docs_scratch");
388 let manifest = Manifest::parse(&value.to_string()).unwrap();
389 assert_eq!(manifest.plan_zone, PlanZone::None);
390 assert_eq!(manifest.docs_scratch, None);
391 }
392
393 #[test]
394 fn the_plan_zone_round_trips_through_its_tagged_form() {
395 let manifest = sample();
396 let json = manifest.to_json();
397 assert!(json.contains("\"kind\": \"tracked\""));
398 assert_eq!(Manifest::parse(&json).unwrap(), manifest);
399
400 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
401 value["plan_zone"] = serde_json::json!({"kind": "none"});
402 assert_eq!(
403 Manifest::parse(&value.to_string()).unwrap().plan_zone,
404 PlanZone::None
405 );
406 }
407
408 #[test]
409 fn the_plan_zone_argument_takes_four_forms() {
410 assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
411 assert_eq!(PlanZone::parse("env").unwrap(), PlanZone::Env);
412 assert_eq!(
413 PlanZone::parse("docs/plan").unwrap(),
414 PlanZone::Tracked {
415 path: "docs/plan".into()
416 }
417 );
418 assert_eq!(
419 PlanZone::parse("untracked:docs/plan").unwrap(),
420 PlanZone::Untracked {
421 path: "docs/plan".into()
422 }
423 );
424 assert_eq!(
426 PlanZone::parse("./none").unwrap(),
427 PlanZone::Tracked {
428 path: "none".into()
429 }
430 );
431 }
432
433 #[test]
434 fn a_plan_zone_never_leaves_the_repository_and_a_docs_scratch_may() {
435 assert!(PlanZone::parse("/etc/plan").is_err());
436 assert!(PlanZone::parse("../plan").is_err());
437 assert!(PlanZone::parse("untracked:../plan").is_err());
438 assert!(PlanZone::parse(" ").is_err());
439
440 assert_eq!(
441 parse_docs_scratch("../beside-the-checkout").unwrap(),
442 Some(Utf8PathBuf::from("../beside-the-checkout"))
443 );
444 assert!(parse_docs_scratch("/tmp/scratch").is_err());
445 assert!(parse_docs_scratch("").is_err());
446 }
447
448 #[test]
450 fn the_tracked_prefix_is_refused_rather_than_absorbed() {
451 let error = PlanZone::parse("tracked:docs/plan").unwrap_err();
452 assert!(error.to_string().contains("bare path"), "{error}");
453 }
454
455 #[test]
457 fn each_declared_location_can_be_cleared() {
458 assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
459 assert_eq!(parse_docs_scratch("none").unwrap(), None);
460 assert_eq!(
462 parse_docs_scratch("./none").unwrap(),
463 Some(Utf8PathBuf::from("none"))
464 );
465 }
466
467 #[test]
471 fn a_recorded_location_the_arguments_would_refuse_is_invalid() {
472 for zone in [
473 serde_json::json!({"kind": "tracked", "path": ""}),
474 serde_json::json!({"kind": "tracked", "path": "/etc"}),
475 serde_json::json!({"kind": "untracked", "path": "../plan"}),
476 ] {
477 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
478 value["plan_zone"] = zone.clone();
479 assert!(
480 matches!(
481 Manifest::parse(&value.to_string()),
482 Err(ManifestParseError::Invalid(_))
483 ),
484 "{zone} was accepted"
485 );
486 }
487
488 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
489 value["docs_scratch"] = "/tmp/scratch".into();
490 assert!(matches!(
491 Manifest::parse(&value.to_string()),
492 Err(ManifestParseError::Invalid(_))
493 ));
494 }
495
496 #[test]
497 fn rejects_unknown_fields_and_empty_managed_sets() {
498 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
499 value["canon_ref"] = "v0.2.0".into();
500 assert!(matches!(
501 Manifest::parse(&value.to_string()),
502 Err(ManifestParseError::Invalid(_))
503 ));
504
505 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
506 value["managed_files"] = serde_json::Value::Array(vec![]);
507 assert!(matches!(
508 Manifest::parse(&value.to_string()),
509 Err(ManifestParseError::Invalid(_))
510 ));
511 }
512
513 #[test]
514 fn legacy_manifest_reads_a_version_one_shape() {
515 let json = r#"{
516 "schema_version": 1,
517 "canon_version": "0.1.6",
518 "canon_source": "https://github.com/gubasso/spec-driven-docs",
519 "canon_ref": "pre-release",
520 "profile": "knowledge-base",
521 "docs_root": "_docs",
522 "installed_at": "2026-08-24T00:00:00Z",
523 "managed_files": [
524 {"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
525 "sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
526 ],
527 "adopted_files": [],
528 "integration_blocks": []
529 }"#;
530 let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
531 assert_eq!(legacy.schema_version, 1);
532 assert_eq!(legacy.canon_version.to_string(), "0.1.6");
533 assert_eq!(legacy.managed_files.len(), 1);
534 assert!(legacy.integration_blocks.is_empty());
535 }
536
537 #[test]
542 fn legacy_manifest_reads_the_version_two_shape() {
543 let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
544 value["schema_version"] = 2.into();
545 let object = value.as_object_mut().unwrap();
546 object.remove("plan_zone");
547 object.remove("docs_scratch");
548 value["integration_blocks"] = serde_json::json!([{
549 "path": ".pre-commit-config.yaml",
550 "marker_hash": Sha256::of(b"block").to_string(),
551 }]);
552 let legacy: LegacyManifest = serde_json::from_str(&value.to_string()).unwrap();
553 assert_eq!(legacy.integration_blocks.len(), 1);
554 assert_eq!(legacy.integration_blocks[0].path, ".pre-commit-config.yaml");
555 }
556}