use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::domain::ownership::{AdoptedEntry, IntegrationBlock, ManagedEntry};
use crate::domain::profile::{DocsRoot, ProfileId};
use crate::domain::version::CanonVersion;
pub const SCHEMA_VERSION: u32 = 3;
pub const CANON_SOURCE: &str = "https://github.com/gubasso/spec-driven-docs";
pub const INSTANCE_DIR: &str = ".spec-driven-docs";
pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
pub const PLAN_ZONE_VAR: &str = "SDD_PLAN_ZONE";
pub const DOCS_SCRATCH_VAR: &str = "SDD_DOCS_SCRATCH";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum PlanZone {
Tracked {
path: Utf8PathBuf,
},
Untracked {
path: Utf8PathBuf,
},
Env,
#[default]
None,
}
#[derive(Debug, Error, PartialEq, Eq)]
#[error("{0}")]
pub struct DeclaredPathError(String);
fn declared_path(value: &str, parents: bool) -> Result<Utf8PathBuf, DeclaredPathError> {
let value = value.trim();
if value.is_empty() {
return Err(DeclaredPathError("the path is empty".to_string()));
}
let path = Utf8Path::new(value);
if path.is_absolute() {
return Err(DeclaredPathError(format!("{value} is not relative")));
}
let mut normalized = Utf8PathBuf::new();
for component in path.components() {
match component {
camino::Utf8Component::CurDir => {}
camino::Utf8Component::ParentDir if parents => normalized.push(".."),
camino::Utf8Component::ParentDir => {
return Err(DeclaredPathError(format!("{value} leaves the repository")));
}
other => normalized.push(other.as_str()),
}
}
if normalized.as_str().is_empty() {
return Err(DeclaredPathError(format!("{value} names no directory")));
}
Ok(normalized)
}
impl PlanZone {
pub fn parse(value: &str) -> Result<Self, DeclaredPathError> {
match value.trim() {
"none" => Ok(Self::None),
"env" => Ok(Self::Env),
rest if rest.starts_with("tracked:") => Err(DeclaredPathError(
"a tracked zone is written as the bare path; `untracked:` is the only prefix"
.to_string(),
)),
rest => match rest.strip_prefix("untracked:") {
Some(path) => Ok(Self::Untracked {
path: declared_path(path, false)?,
}),
None => Ok(Self::Tracked {
path: declared_path(rest, false)?,
}),
},
}
}
#[must_use]
pub const fn path(&self) -> Option<&Utf8PathBuf> {
match self {
Self::Tracked { path } | Self::Untracked { path } => Some(path),
Self::Env | Self::None => None,
}
}
}
pub fn validate_plan_zone_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
declared_path(path.as_str(), false).map(|_| ())
}
pub fn validate_docs_scratch_path(path: &Utf8Path) -> Result<(), DeclaredPathError> {
declared_path(path.as_str(), true).map(|_| ())
}
pub fn parse_docs_scratch(value: &str) -> Result<Option<Utf8PathBuf>, DeclaredPathError> {
if value.trim() == "none" {
return Ok(None);
}
declared_path(value, true).map(Some)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema_version: u32,
pub canon_version: CanonVersion,
pub canon_source: String,
pub profile: ProfileId,
pub docs_root: DocsRoot,
pub installed_at: String,
#[serde(default)]
pub plan_zone: PlanZone,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docs_scratch: Option<Utf8PathBuf>,
pub managed_files: Vec<ManagedEntry>,
pub adopted_files: Vec<AdoptedEntry>,
pub integration_blocks: Vec<IntegrationBlock>,
}
#[derive(Debug, Error)]
pub enum ManifestParseError {
#[error("invalid manifest schema: {0}")]
Invalid(String),
#[error("manifest schema_version {0} is older than this binary's; run 'sdd upgrade'")]
Older(u32),
#[error("manifest schema_version {0} is newer than this binary's; upgrade sdd")]
Newer(u32),
}
impl Manifest {
pub fn parse(json: &str) -> Result<Self, ManifestParseError> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
match value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
{
Some(v) if v == u64::from(SCHEMA_VERSION) => {}
Some(v) if v < u64::from(SCHEMA_VERSION) => {
return Err(ManifestParseError::Older(u32::try_from(v).unwrap_or(0)));
}
Some(v) => {
return Err(ManifestParseError::Newer(
u32::try_from(v).unwrap_or(u32::MAX),
));
}
None => {
return Err(ManifestParseError::Invalid(
"no numeric schema_version".to_string(),
));
}
}
let manifest: Self = serde_json::from_value(value)
.map_err(|e| ManifestParseError::Invalid(e.to_string()))?;
if manifest.managed_files.is_empty() {
return Err(ManifestParseError::Invalid(
"managed_files is empty".to_string(),
));
}
if let Some(path) = manifest.plan_zone.path()
&& let Err(error) = validate_plan_zone_path(path)
{
return Err(ManifestParseError::Invalid(format!("plan_zone: {error}")));
}
if let Some(path) = &manifest.docs_scratch
&& let Err(error) = validate_docs_scratch_path(path)
{
return Err(ManifestParseError::Invalid(format!(
"docs_scratch: {error}"
)));
}
let mut paths = std::collections::BTreeSet::new();
for block in &manifest.integration_blocks {
if !paths.insert(&block.path) {
return Err(ManifestParseError::Invalid(format!(
"duplicate integration block path: {}",
block.path
)));
}
}
Ok(manifest)
}
#[must_use]
pub fn to_json(&self) -> String {
let mut json = serde_json::to_string_pretty(self).unwrap_or_default();
json.push('\n');
json
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LegacyManifest {
pub schema_version: u32,
pub canon_version: CanonVersion,
pub profile: ProfileId,
pub docs_root: DocsRoot,
pub installed_at: String,
pub managed_files: Vec<LegacyOwnedFile>,
#[serde(default)]
pub integration_blocks: Vec<IntegrationBlock>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct LegacyOwnedFile {
pub destination: Utf8PathBuf,
pub sha256: crate::domain::ownership::Sha256,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::ownership::Sha256;
fn sample() -> Manifest {
Manifest {
schema_version: SCHEMA_VERSION,
canon_version: "0.2.0".parse().unwrap(),
canon_source: CANON_SOURCE.to_string(),
profile: ProfileId::KnowledgeBase,
docs_root: DocsRoot::UnderscoreDocs,
installed_at: "2026-08-25T00:00:00Z".to_string(),
plan_zone: PlanZone::Tracked {
path: "tests/fixtures".into(),
},
docs_scratch: Some("scratch".into()),
managed_files: vec![ManagedEntry {
source: ".markdownlint/spec.markdownlint-cli2.jsonc".into(),
destination: ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc".into(),
sha256: Sha256::of(b"x"),
}],
adopted_files: vec![],
integration_blocks: vec![],
}
}
#[test]
fn round_trips_through_json() {
let manifest = sample();
let json = manifest.to_json();
assert!(json.ends_with('\n'));
assert_eq!(Manifest::parse(&json).unwrap(), manifest);
}
#[test]
fn rejects_an_older_schema_as_upgradable() {
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["schema_version"] = 1.into();
assert!(matches!(
Manifest::parse(&value.to_string()),
Err(ManifestParseError::Older(1))
));
}
#[test]
fn rejects_a_newer_schema_as_binary_too_old() {
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["schema_version"] = 4.into();
assert!(matches!(
Manifest::parse(&value.to_string()),
Err(ManifestParseError::Newer(4))
));
}
#[test]
fn a_record_without_the_declared_locations_defaults_them() {
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value.as_object_mut().unwrap().remove("plan_zone");
value.as_object_mut().unwrap().remove("docs_scratch");
let manifest = Manifest::parse(&value.to_string()).unwrap();
assert_eq!(manifest.plan_zone, PlanZone::None);
assert_eq!(manifest.docs_scratch, None);
}
#[test]
fn the_plan_zone_round_trips_through_its_tagged_form() {
let manifest = sample();
let json = manifest.to_json();
assert!(json.contains("\"kind\": \"tracked\""));
assert_eq!(Manifest::parse(&json).unwrap(), manifest);
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
value["plan_zone"] = serde_json::json!({"kind": "none"});
assert_eq!(
Manifest::parse(&value.to_string()).unwrap().plan_zone,
PlanZone::None
);
}
#[test]
fn the_plan_zone_argument_takes_four_forms() {
assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
assert_eq!(PlanZone::parse("env").unwrap(), PlanZone::Env);
assert_eq!(
PlanZone::parse("docs/plan").unwrap(),
PlanZone::Tracked {
path: "docs/plan".into()
}
);
assert_eq!(
PlanZone::parse("untracked:docs/plan").unwrap(),
PlanZone::Untracked {
path: "docs/plan".into()
}
);
assert_eq!(
PlanZone::parse("./none").unwrap(),
PlanZone::Tracked {
path: "none".into()
}
);
}
#[test]
fn a_plan_zone_never_leaves_the_repository_and_a_docs_scratch_may() {
assert!(PlanZone::parse("/etc/plan").is_err());
assert!(PlanZone::parse("../plan").is_err());
assert!(PlanZone::parse("untracked:../plan").is_err());
assert!(PlanZone::parse(" ").is_err());
assert_eq!(
parse_docs_scratch("../beside-the-checkout").unwrap(),
Some(Utf8PathBuf::from("../beside-the-checkout"))
);
assert!(parse_docs_scratch("/tmp/scratch").is_err());
assert!(parse_docs_scratch("").is_err());
}
#[test]
fn the_tracked_prefix_is_refused_rather_than_absorbed() {
let error = PlanZone::parse("tracked:docs/plan").unwrap_err();
assert!(error.to_string().contains("bare path"), "{error}");
}
#[test]
fn each_declared_location_can_be_cleared() {
assert_eq!(PlanZone::parse("none").unwrap(), PlanZone::None);
assert_eq!(parse_docs_scratch("none").unwrap(), None);
assert_eq!(
parse_docs_scratch("./none").unwrap(),
Some(Utf8PathBuf::from("none"))
);
}
#[test]
fn a_recorded_location_the_arguments_would_refuse_is_invalid() {
for zone in [
serde_json::json!({"kind": "tracked", "path": ""}),
serde_json::json!({"kind": "tracked", "path": "/etc"}),
serde_json::json!({"kind": "untracked", "path": "../plan"}),
] {
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["plan_zone"] = zone.clone();
assert!(
matches!(
Manifest::parse(&value.to_string()),
Err(ManifestParseError::Invalid(_))
),
"{zone} was accepted"
);
}
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["docs_scratch"] = "/tmp/scratch".into();
assert!(matches!(
Manifest::parse(&value.to_string()),
Err(ManifestParseError::Invalid(_))
));
}
#[test]
fn rejects_unknown_fields_and_empty_managed_sets() {
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["canon_ref"] = "v0.2.0".into();
assert!(matches!(
Manifest::parse(&value.to_string()),
Err(ManifestParseError::Invalid(_))
));
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["managed_files"] = serde_json::Value::Array(vec![]);
assert!(matches!(
Manifest::parse(&value.to_string()),
Err(ManifestParseError::Invalid(_))
));
}
#[test]
fn legacy_manifest_reads_a_version_one_shape() {
let json = r#"{
"schema_version": 1,
"canon_version": "0.1.6",
"canon_source": "https://github.com/gubasso/spec-driven-docs",
"canon_ref": "pre-release",
"profile": "knowledge-base",
"docs_root": "_docs",
"installed_at": "2026-08-24T00:00:00Z",
"managed_files": [
{"source": "scripts/verify.sh", "destination": ".spec-driven-docs/verify.sh",
"sha256": "dc17d596ae2c196cc01b439c291416f91198cc274e2376fd01a4d614c1ff60ad"}
],
"adopted_files": [],
"integration_blocks": []
}"#;
let legacy: LegacyManifest = serde_json::from_str(json).unwrap();
assert_eq!(legacy.schema_version, 1);
assert_eq!(legacy.canon_version.to_string(), "0.1.6");
assert_eq!(legacy.managed_files.len(), 1);
assert!(legacy.integration_blocks.is_empty());
}
#[test]
fn legacy_manifest_reads_the_version_two_shape() {
let mut value: serde_json::Value = serde_json::from_str(&sample().to_json()).unwrap();
value["schema_version"] = 2.into();
let object = value.as_object_mut().unwrap();
object.remove("plan_zone");
object.remove("docs_scratch");
value["integration_blocks"] = serde_json::json!([{
"path": ".pre-commit-config.yaml",
"marker_hash": Sha256::of(b"block").to_string(),
}]);
let legacy: LegacyManifest = serde_json::from_str(&value.to_string()).unwrap();
assert_eq!(legacy.integration_blocks.len(), 1);
assert_eq!(legacy.integration_blocks[0].path, ".pre-commit-config.yaml");
}
}