use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const PLAN_SCHEMA_VERSION: u32 = 3;
pub const SUPPORTED_PLAN_SCHEMAS: &[u32] = &[3];
pub const PROVENANCE_REQUIRED_SCHEMA: u32 = 3;
pub const TOLERATED_OPTIONAL_FIELDS: &[&str] = &[];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ObjectShape {
Plan,
Feature,
Baseline,
Chunk,
Check,
}
impl ObjectShape {
fn label(self) -> &'static str {
match self {
ObjectShape::Plan => "<plan>",
ObjectShape::Feature => "feature",
ObjectShape::Baseline => "baseline",
ObjectShape::Chunk => "chunks[]",
ObjectShape::Check => "checks[]",
}
}
}
const fn tolerated_fields(shape: ObjectShape) -> &'static [&'static str] {
match shape {
ObjectShape::Plan
| ObjectShape::Feature
| ObjectShape::Baseline
| ObjectShape::Chunk
| ObjectShape::Check => &[],
}
}
pub const PLAN_V3_JSON_SCHEMA: &str = include_str!("../schemas/plan.v3.schema.json");
#[must_use]
pub fn plan_v3_json_schema() -> &'static str {
PLAN_V3_JSON_SCHEMA
}
pub const PLAN_V3_EXAMPLE: &str = include_str!("../schemas/plan.v3.example.json");
#[must_use]
pub fn plan_v3_json_schema_example() -> &'static str {
PLAN_V3_EXAMPLE
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Plan {
pub schema_version: u32,
pub plan_rev: u32,
pub intent_rev: u32,
pub feature: Feature,
pub baseline: Baseline,
pub acceptance: Vec<Acceptance>,
pub chunks: Vec<Chunk>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Feature {
pub slug: String,
pub source_branch: String,
pub integration_branch: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Baseline {
pub r#ref: String,
pub commit_oid: String,
pub toolchain: String,
pub test_passlist_hash: String,
pub clippy_warnings_hash: String,
pub enumerated_targets_hash: String,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum Acceptance {
Check {
desc: String,
run: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
expect_exit: Option<i32>,
},
Assertion {
desc: String,
},
}
impl Acceptance {
#[must_use]
pub fn is_check(&self) -> bool {
matches!(self, Acceptance::Check { .. })
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Chunk {
pub id: String,
pub title: String,
#[serde(default)]
pub deps: Vec<String>,
pub tier: Tier,
pub brief: String,
pub files_touched: Vec<String>,
pub checks: Vec<Check>,
#[serde(default)]
pub assertions: Vec<String>,
#[serde(default)]
pub requires_tests: bool,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Check {
pub desc: String,
pub run: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_exit: Option<i32>,
#[serde(flatten)]
pub extra: Map<String, Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
Code,
Mid,
High,
}
impl Tier {
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Tier::Code => "code",
Tier::Mid => "mid",
Tier::High => "high",
}
}
pub const WIRE_NAMES: &'static [&'static str] = &[
Tier::Code.wire_name(),
Tier::Mid.wire_name(),
Tier::High.wire_name(),
];
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PlanValidationError {
#[error("plan must be a JSON object")]
NotObject,
#[error("plan missing required field `schema_version`")]
SchemaVersionMissing,
#[error("field `schema_version` must be a non-negative integer")]
SchemaVersionNotInt,
#[error("unsupported plan schema_version {found} (supported: {supported:?})")]
UnsupportedSchemaVersion {
found: u64,
supported: Vec<u32>,
},
#[error("plan is malformed: {message}")]
Malformed {
message: String,
},
#[error("unknown field `{field}` at {path} (not a tolerated additive optional field)")]
UnknownField {
path: String,
field: String,
},
#[error("field `{path}` must be a non-empty string")]
EmptyString {
path: String,
},
#[error("`acceptance` must contain at least one item")]
AcceptanceEmpty,
#[error("`acceptance` must contain at least one executable check (not all assertions)")]
AcceptanceNoCheck,
#[error("`chunks` must contain at least one chunk")]
ChunksEmpty,
#[error("chunk id {id:?} is invalid: expected {expected}")]
InvalidChunkId {
id: String,
expected: &'static str,
},
#[error("duplicate chunk id {id:?}")]
DuplicateChunkId {
id: String,
},
#[error("chunk {chunk:?} depends on unknown chunk {dep:?}")]
UnknownDep {
chunk: String,
dep: String,
},
#[error("chunk {chunk:?} lists duplicate dependency {dep:?}")]
DuplicateDep {
chunk: String,
dep: String,
},
#[error("chunk dependency graph has a cycle: {}", cycle.join(" -> "))]
DependencyCycle {
cycle: Vec<String>,
},
#[error("chunk {chunk:?} must have at least one check")]
ChunkNoCheck {
chunk: String,
},
#[error("chunk {chunk:?} must declare at least one file in `files_touched`")]
ChunkNoFiles {
chunk: String,
},
#[error("path {path:?} in chunk {chunk:?} is not a safe repo-relative path (no absolute paths, `~`, `\\`, `:`, control chars, or `.`/`..`/empty components)")]
UnsafePath {
chunk: String,
path: String,
},
#[error("cwd {path:?} at {location} is not a safe repo-relative directory (no absolute paths, `~`, `\\`, `:`, control chars, or `.`/`..`/empty components; omit `cwd` for the worktree root)")]
UnsafeCwd {
location: String,
path: String,
},
#[error("expect_exit {value} at {location} is out of range (a shell exit status is 0..=255)")]
ExpectExitOutOfRange {
location: String,
value: i64,
},
}
impl PlanValidationError {
#[must_use]
pub fn expected(&self) -> Option<Value> {
match self {
Self::SchemaVersionMissing | Self::SchemaVersionNotInt => {
Some(serde_json::json!({"field": "schema_version", "type": "integer"}))
}
Self::UnsupportedSchemaVersion { supported, .. } => {
Some(serde_json::json!({"field": "schema_version", "supported": supported}))
}
Self::UnknownField { .. } => {
Some(serde_json::json!({"tolerated_optional": TOLERATED_OPTIONAL_FIELDS}))
}
_ => None,
}
}
}
pub fn parse_and_validate_plan(raw: &Value) -> Result<Plan, PlanValidationError> {
let obj = raw.as_object().ok_or(PlanValidationError::NotObject)?;
let version = obj
.get("schema_version")
.ok_or(PlanValidationError::SchemaVersionMissing)?;
let version = version
.as_u64()
.ok_or(PlanValidationError::SchemaVersionNotInt)?;
check_supported_version(version)?;
let plan: Plan =
serde_json::from_value(raw.clone()).map_err(|e| PlanValidationError::Malformed {
message: e.to_string(),
})?;
validate_plan(&plan)?;
Ok(plan)
}
fn check_supported_version(version: u64) -> Result<(), PlanValidationError> {
if u32::try_from(version).is_ok_and(|v| SUPPORTED_PLAN_SCHEMAS.contains(&v)) {
Ok(())
} else {
Err(PlanValidationError::UnsupportedSchemaVersion {
found: version,
supported: SUPPORTED_PLAN_SCHEMAS.to_vec(),
})
}
}
pub fn validate_plan(plan: &Plan) -> Result<(), PlanValidationError> {
check_supported_version(u64::from(plan.schema_version))?;
reject_unknown_fields(&plan.extra, ObjectShape::Plan)?;
reject_unknown_fields(&plan.feature.extra, ObjectShape::Feature)?;
reject_unknown_fields(&plan.baseline.extra, ObjectShape::Baseline)?;
non_empty(&plan.feature.slug, "feature.slug")?;
non_empty(&plan.feature.source_branch, "feature.source_branch")?;
non_empty(
&plan.feature.integration_branch,
"feature.integration_branch",
)?;
non_empty(&plan.baseline.r#ref, "baseline.ref")?;
non_empty(
&plan.baseline.test_passlist_hash,
"baseline.test_passlist_hash",
)?;
non_empty(
&plan.baseline.clippy_warnings_hash,
"baseline.clippy_warnings_hash",
)?;
debug_assert!(
plan.schema_version >= PROVENANCE_REQUIRED_SCHEMA,
"supported majors must all require provenance; \
a lower major needs its own wire type, not a skipped gate"
);
if plan.schema_version >= PROVENANCE_REQUIRED_SCHEMA {
non_empty(&plan.baseline.commit_oid, "baseline.commit_oid")?;
non_empty(&plan.baseline.toolchain, "baseline.toolchain")?;
non_empty(
&plan.baseline.enumerated_targets_hash,
"baseline.enumerated_targets_hash",
)?;
}
if plan.acceptance.is_empty() {
return Err(PlanValidationError::AcceptanceEmpty);
}
for (i, item) in plan.acceptance.iter().enumerate() {
match item {
Acceptance::Check {
desc,
run,
cwd,
expect_exit,
} => {
non_empty(desc, &format!("acceptance[{i}].desc"))?;
non_empty(run, &format!("acceptance[{i}].run"))?;
validate_check_precision(
cwd.as_deref(),
*expect_exit,
&format!("acceptance[{i}]"),
)?;
}
Acceptance::Assertion { desc } => {
non_empty(desc, &format!("acceptance[{i}].desc"))?;
}
}
}
if !plan.acceptance.iter().any(Acceptance::is_check) {
return Err(PlanValidationError::AcceptanceNoCheck);
}
if plan.chunks.is_empty() {
return Err(PlanValidationError::ChunksEmpty);
}
let mut ids: HashSet<&str> = HashSet::with_capacity(plan.chunks.len());
for chunk in &plan.chunks {
validate_chunk_id(&chunk.id)?;
if !ids.insert(chunk.id.as_str()) {
return Err(PlanValidationError::DuplicateChunkId {
id: chunk.id.clone(),
});
}
}
for chunk in &plan.chunks {
validate_chunk(chunk, &ids)?;
}
detect_cycle(&plan.chunks)?;
Ok(())
}
fn reject_unknown_fields_at(
extra: &Map<String, Value>,
shape: ObjectShape,
path: &str,
) -> Result<(), PlanValidationError> {
let allow = tolerated_fields(shape);
if let Some((field, _)) = extra.iter().find(|(k, _)| !allow.contains(&k.as_str())) {
return Err(PlanValidationError::UnknownField {
path: path.to_string(),
field: field.clone(),
});
}
Ok(())
}
fn reject_unknown_fields(
extra: &Map<String, Value>,
shape: ObjectShape,
) -> Result<(), PlanValidationError> {
reject_unknown_fields_at(extra, shape, shape.label())
}
fn non_empty(s: &str, path: &str) -> Result<(), PlanValidationError> {
if s.trim().is_empty() {
return Err(PlanValidationError::EmptyString {
path: path.to_string(),
});
}
Ok(())
}
const MAX_SHELL_EXIT: i32 = 255;
fn validate_check_precision(
cwd: Option<&str>,
expect_exit: Option<i32>,
location: &str,
) -> Result<(), PlanValidationError> {
if let Some(cwd) = cwd {
non_empty(cwd, &format!("{location}.cwd"))?;
if !is_safe_repo_relative(cwd) {
return Err(PlanValidationError::UnsafeCwd {
location: format!("{location}.cwd"),
path: cwd.to_string(),
});
}
}
if let Some(code) = expect_exit {
if !(0..=MAX_SHELL_EXIT).contains(&code) {
return Err(PlanValidationError::ExpectExitOutOfRange {
location: format!("{location}.expect_exit"),
value: i64::from(code),
});
}
}
Ok(())
}
const CHUNK_ID_EXPECTED: &str = "a non-empty id of `[A-Za-z0-9_.-]` starting with an alphanumeric";
fn validate_chunk_id(id: &str) -> Result<(), PlanValidationError> {
let ok = {
let mut chars = id.chars();
chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
};
if ok {
Ok(())
} else {
Err(PlanValidationError::InvalidChunkId {
id: id.to_string(),
expected: CHUNK_ID_EXPECTED,
})
}
}
fn validate_chunk(chunk: &Chunk, ids: &HashSet<&str>) -> Result<(), PlanValidationError> {
reject_unknown_fields_at(
&chunk.extra,
ObjectShape::Chunk,
&format!("chunks[{}]", chunk.id),
)?;
non_empty(&chunk.title, &format!("chunks[{}].title", chunk.id))?;
non_empty(&chunk.brief, &format!("chunks[{}].brief", chunk.id))?;
let mut seen_deps: HashSet<&str> = HashSet::with_capacity(chunk.deps.len());
for dep in &chunk.deps {
if !ids.contains(dep.as_str()) {
return Err(PlanValidationError::UnknownDep {
chunk: chunk.id.clone(),
dep: dep.clone(),
});
}
if !seen_deps.insert(dep.as_str()) {
return Err(PlanValidationError::DuplicateDep {
chunk: chunk.id.clone(),
dep: dep.clone(),
});
}
}
if chunk.checks.is_empty() {
return Err(PlanValidationError::ChunkNoCheck {
chunk: chunk.id.clone(),
});
}
for (i, check) in chunk.checks.iter().enumerate() {
reject_unknown_fields_at(
&check.extra,
ObjectShape::Check,
&format!("chunks[{}].checks[{i}]", chunk.id),
)?;
non_empty(
&check.desc,
&format!("chunks[{}].checks[{i}].desc", chunk.id),
)?;
non_empty(&check.run, &format!("chunks[{}].checks[{i}].run", chunk.id))?;
validate_check_precision(
check.cwd.as_deref(),
check.expect_exit,
&format!("chunks[{}].checks[{i}]", chunk.id),
)?;
}
for (i, assertion) in chunk.assertions.iter().enumerate() {
non_empty(assertion, &format!("chunks[{}].assertions[{i}]", chunk.id))?;
}
if chunk.files_touched.is_empty() {
return Err(PlanValidationError::ChunkNoFiles {
chunk: chunk.id.clone(),
});
}
for path in &chunk.files_touched {
if !is_safe_repo_relative(path) {
return Err(PlanValidationError::UnsafePath {
chunk: chunk.id.clone(),
path: path.clone(),
});
}
}
Ok(())
}
fn is_safe_repo_relative(p: &str) -> bool {
if p.is_empty()
|| p.starts_with('/')
|| p.starts_with('~')
|| p.contains('\\')
|| p.contains(':')
|| p.chars().any(char::is_control)
{
return false;
}
p.split('/')
.all(|comp| !comp.trim().is_empty() && comp != "." && comp != "..")
}
fn detect_cycle(chunks: &[Chunk]) -> Result<(), PlanValidationError> {
#[derive(Clone, Copy, PartialEq)]
enum Colour {
White,
Grey,
Black,
}
let adj: HashMap<&str, &[String]> = chunks
.iter()
.map(|c| (c.id.as_str(), c.deps.as_slice()))
.collect();
let mut colour: HashMap<&str, Colour> = chunks
.iter()
.map(|c| (c.id.as_str(), Colour::White))
.collect();
for start in chunks.iter().map(|c| c.id.as_str()) {
if colour[start] != Colour::White {
continue;
}
let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
let mut path: Vec<&str> = vec![start];
colour.insert(start, Colour::Grey);
while let Some(&mut (node, ref mut idx)) = stack.last_mut() {
let deps = adj[node];
if *idx < deps.len() {
let dep = deps[*idx].as_str();
*idx += 1;
match colour[dep] {
Colour::White => {
colour.insert(dep, Colour::Grey);
stack.push((dep, 0));
path.push(dep);
}
Colour::Grey => {
let from = path.iter().position(|&n| n == dep).unwrap_or(0);
let mut cycle: Vec<String> =
path[from..].iter().map(|s| (*s).to_string()).collect();
cycle.push(dep.to_string());
return Err(PlanValidationError::DependencyCycle { cycle });
}
Colour::Black => {}
}
} else {
colour.insert(node, Colour::Black);
stack.pop();
path.pop();
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn valid_plan() -> Value {
serde_json::from_str(include_str!("../schemas/plan.v3.example.json")).unwrap()
}
#[test]
fn example_plan_validates() {
let plan = parse_and_validate_plan(&valid_plan()).expect("example must validate");
assert_eq!(plan.schema_version, PLAN_SCHEMA_VERSION);
assert_eq!(plan.chunks.len(), 2);
assert_eq!(plan.chunks[1].deps, vec!["c1".to_string()]);
assert!(plan.chunks[0].requires_tests);
}
#[test]
fn checked_in_example_is_valid() {
let raw: Value = serde_json::from_str(PLAN_V3_EXAMPLE).unwrap();
assert!(parse_and_validate_plan(&raw).is_ok());
}
fn minimal_baseline() -> Value {
json!({
"ref": "feat/f@fork",
"commit_oid": "0123456789abcdef0123456789abcdef01234567",
"toolchain": "rustc 1.97.1 (abcdef012 2026-06-01)",
"test_passlist_hash": "sha256:a",
"clippy_warnings_hash": "sha256:b",
"enumerated_targets_hash": "sha256:c"
})
}
#[test]
fn minimal_valid_plan() {
let v = json!({
"schema_version": 3,
"plan_rev": 1,
"intent_rev": 1,
"feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
"baseline": minimal_baseline(),
"acceptance": [{"kind": "check", "desc": "e2e", "run": "cargo test"}],
"chunks": [{
"id": "c1", "title": "t", "tier": "code", "brief": "b",
"files_touched": ["src/a.rs"],
"checks": [{"desc": "d", "run": "cargo test a"}]
}],
});
assert!(parse_and_validate_plan(&v).is_ok());
}
#[test]
fn round_trips_through_serde() {
let plan = parse_and_validate_plan(&valid_plan()).unwrap();
let reser = serde_json::to_value(&plan).unwrap();
let again = parse_and_validate_plan(&reser).unwrap();
assert_eq!(plan, again);
}
#[test]
fn unsupported_major_rejected() {
let mut v = valid_plan();
v["schema_version"] = json!(4);
let err = parse_and_validate_plan(&v).unwrap_err();
assert!(matches!(
err,
PlanValidationError::UnsupportedSchemaVersion { found: 4, .. }
));
assert_eq!(
err.expected(),
Some(json!({"field": "schema_version", "supported": [3]}))
);
}
#[test]
fn v2_major_now_unsupported() {
let mut v = valid_plan();
v["schema_version"] = json!(2);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnsupportedSchemaVersion { found: 2, .. }
));
}
#[test]
fn genuine_v2_document_rejected_at_version_gate_not_shape() {
let v = json!({
"schema_version": 2, "plan_rev": 1, "intent_rev": 1,
"feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
"baseline": {"ref": "feat/f@fork", "test_passlist_hash": "h", "clippy_warnings_hash": "h"},
"acceptance": [{"kind": "check", "desc": "e2e", "run": "cargo test"}],
"chunks": [{
"id": "c1", "title": "t", "tier": "code", "brief": "b",
"files_touched": ["src/a.rs"],
"checks": [{"desc": "d", "run": "cargo test a"}]
}],
});
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnsupportedSchemaVersion { found: 2, .. }
));
}
#[test]
fn missing_version_rejected() {
let mut v = valid_plan();
v.as_object_mut().unwrap().remove("schema_version");
assert_eq!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::SchemaVersionMissing
);
}
#[test]
fn non_integer_version_rejected() {
let mut v = valid_plan();
v["schema_version"] = json!("2");
assert_eq!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::SchemaVersionNotInt
);
}
#[test]
fn validate_plan_regates_version_on_typed_plan() {
let mut plan = parse_and_validate_plan(&valid_plan()).unwrap();
plan.schema_version = 4;
assert!(matches!(
validate_plan(&plan).unwrap_err(),
PlanValidationError::UnsupportedSchemaVersion { found: 4, .. }
));
}
#[test]
fn unknown_top_level_field_rejected() {
let mut v = valid_plan();
v["budget"] = json!(1000);
let err = parse_and_validate_plan(&v).unwrap_err();
assert!(matches!(
err,
PlanValidationError::UnknownField { ref field, .. } if field == "budget"
));
}
#[test]
fn unknown_chunk_field_rejected() {
let mut v = valid_plan();
v["chunks"][0]["retries"] = json!(3);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnknownField { field, .. } if field == "retries"
));
}
#[test]
fn unknown_acceptance_kind_rejected() {
let mut v = valid_plan();
v["acceptance"][0]["kind"] = json!("gut-feeling");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn unknown_tier_rejected() {
let mut v = valid_plan();
v["chunks"][0]["tier"] = json!("ultra");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn missing_required_chunk_field_rejected() {
let mut v = valid_plan();
v["chunks"][0].as_object_mut().unwrap().remove("brief");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn non_object_root_rejected() {
assert_eq!(
parse_and_validate_plan(&json!([1, 2, 3])).unwrap_err(),
PlanValidationError::NotObject
);
}
#[test]
fn acceptance_all_assertions_rejected() {
let mut v = valid_plan();
v["acceptance"] = json!([{"kind": "assertion", "desc": "vibes"}]);
assert_eq!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::AcceptanceNoCheck
);
}
#[test]
fn acceptance_empty_rejected() {
let mut v = valid_plan();
v["acceptance"] = json!([]);
assert_eq!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::AcceptanceEmpty
);
}
#[test]
fn acceptance_check_unknown_field_rejected() {
let mut v = valid_plan();
v["acceptance"][0]["budget"] = json!(100);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn acceptance_assertion_with_run_rejected() {
let mut v = valid_plan();
v["acceptance"] = json!([
{"kind": "check", "desc": "e2e", "run": "cargo test"},
{"kind": "assertion", "desc": "x", "run": "rm -rf /"},
]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn chunk_missing_check_rejected() {
let mut v = valid_plan();
v["chunks"][0]["checks"] = json!([]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::ChunkNoCheck { chunk } if chunk == "c1"
));
}
#[test]
fn chunk_empty_files_touched_rejected() {
let mut v = valid_plan();
v["chunks"][0]["files_touched"] = json!([]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::ChunkNoFiles { chunk } if chunk == "c1"
));
}
#[test]
fn duplicate_chunk_id_rejected() {
let mut v = valid_plan();
v["chunks"][1]["id"] = json!("c1");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::DuplicateChunkId { id } if id == "c1"
));
}
#[test]
fn dangling_dep_rejected() {
let mut v = valid_plan();
v["chunks"][1]["deps"] = json!(["nope"]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnknownDep { dep, .. } if dep == "nope"
));
}
#[test]
fn invalid_chunk_id_rejected() {
let mut v = valid_plan();
v["chunks"][0]["id"] = json!("../evil");
v["chunks"][1]["deps"] = json!([]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::InvalidChunkId { .. }
));
}
#[test]
fn duplicate_dep_rejected() {
let mut v = valid_plan();
v["chunks"][1]["deps"] = json!(["c1", "c1"]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::DuplicateDep { dep, .. } if dep == "c1"
));
}
#[test]
fn empty_chunk_assertion_rejected() {
let mut v = valid_plan();
v["chunks"][0]["assertions"] = json!(["ok", " "]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::EmptyString { path } if path == "chunks[c1].assertions[1]"
));
}
#[test]
fn check_with_cwd_and_expect_exit_validates_and_round_trips() {
let mut v = valid_plan();
v["chunks"][0]["checks"] = json!([
{"desc": "runs in a subdir with a non-zero expected code",
"run": "make check", "cwd": "crates/x", "expect_exit": 2},
]);
v["acceptance"] = json!([
{"kind": "check", "desc": "e2e", "run": "cargo test", "cwd": "tests", "expect_exit": 0},
]);
let plan = parse_and_validate_plan(&v).expect("optional check fields must validate");
let check = &plan.chunks[0].checks[0];
assert_eq!(check.cwd.as_deref(), Some("crates/x"));
assert_eq!(check.expect_exit, Some(2));
assert!(check.extra.is_empty());
assert!(matches!(
&plan.acceptance[0],
Acceptance::Check { cwd, expect_exit, .. }
if cwd.as_deref() == Some("tests") && *expect_exit == Some(0)
));
let reser = serde_json::to_value(&plan).unwrap();
assert_eq!(parse_and_validate_plan(&reser).unwrap(), plan);
}
#[test]
fn check_without_optional_fields_defaults() {
let plan = parse_and_validate_plan(&valid_plan()).unwrap();
let check = &plan.chunks[0].checks[0];
assert_eq!(check.cwd, None);
assert_eq!(check.expect_exit, None);
let reser = serde_json::to_value(&plan.chunks[0].checks[0]).unwrap();
let obj = reser.as_object().unwrap();
assert!(!obj.contains_key("cwd"));
assert!(!obj.contains_key("expect_exit"));
}
#[test]
fn empty_check_cwd_rejected() {
let mut v = valid_plan();
v["chunks"][0]["checks"][0]["cwd"] = json!(" ");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::EmptyString { path } if path == "chunks[c1].checks[0].cwd"
));
}
#[test]
fn empty_acceptance_check_cwd_rejected() {
let mut v = valid_plan();
v["acceptance"][0]["cwd"] = json!("");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::EmptyString { path } if path == "acceptance[0].cwd"
));
}
#[test]
fn non_integer_expect_exit_rejected() {
let mut v = valid_plan();
v["chunks"][0]["checks"][0]["expect_exit"] = json!("nope");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn unsafe_check_cwd_rejected() {
for bad in [
"/etc",
"../../outside",
"a/../../etc",
"~/secret",
".",
"a\\b",
] {
let mut v = valid_plan();
v["chunks"][0]["checks"][0]["cwd"] = json!(bad);
assert!(
matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnsafeCwd { location, .. }
if location == "chunks[c1].checks[0].cwd"
),
"expected UnsafeCwd for chunk cwd {bad:?}"
);
}
}
#[test]
fn unsafe_acceptance_check_cwd_rejected() {
for bad in ["/etc", "../escape", "~/x", "."] {
let mut v = valid_plan();
v["acceptance"][0]["cwd"] = json!(bad);
assert!(
matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnsafeCwd { location, .. }
if location == "acceptance[0].cwd"
),
"expected UnsafeCwd for acceptance cwd {bad:?}"
);
}
}
#[test]
fn out_of_range_expect_exit_rejected() {
for (loc, patch) in [
(
"chunks[c1].checks[0].expect_exit",
(&["chunks", "0", "checks", "0"][..], -1),
),
(
"chunks[c1].checks[0].expect_exit",
(&["chunks", "0", "checks", "0"][..], 256),
),
("acceptance[0].expect_exit", (&["acceptance", "0"][..], 300)),
] {
let mut v = valid_plan();
let (path, code) = patch;
let mut node = &mut v;
for key in path {
node = match key.parse::<usize>() {
Ok(idx) => &mut node[idx],
Err(_) => &mut node[key],
};
}
node["expect_exit"] = json!(code);
assert!(
matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::ExpectExitOutOfRange { location, value }
if location == loc && value == i64::from(code)
),
"expected ExpectExitOutOfRange for {loc} = {code}"
);
}
}
#[test]
fn boundary_expect_exit_accepted() {
for code in [0, 255] {
let mut v = valid_plan();
v["chunks"][0]["checks"][0]["expect_exit"] = json!(code);
assert!(
parse_and_validate_plan(&v).is_ok(),
"expect_exit {code} should be accepted"
);
}
}
#[test]
fn self_loop_rejected() {
let mut v = valid_plan();
v["chunks"][0]["deps"] = json!(["c1"]);
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::DependencyCycle { .. }
));
}
#[test]
fn two_cycle_rejected() {
let mut v = valid_plan();
v["chunks"][0]["deps"] = json!(["c2"]);
v["chunks"][1]["deps"] = json!(["c1"]);
let err = parse_and_validate_plan(&v).unwrap_err();
match err {
PlanValidationError::DependencyCycle { cycle } => {
assert_eq!(cycle.first(), cycle.last());
assert!(cycle.contains(&"c1".to_string()));
assert!(cycle.contains(&"c2".to_string()));
}
other => panic!("expected cycle, got {other:?}"),
}
}
#[test]
fn longer_cycle_rejected() {
let v = json!({
"schema_version": 3, "plan_rev": 1, "intent_rev": 1,
"feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
"baseline": minimal_baseline(),
"acceptance": [{"kind": "check", "desc": "e2e", "run": "t"}],
"chunks": [
{"id": "a", "title": "t", "tier": "code", "brief": "b", "deps": ["c"], "files_touched": ["x"], "checks": [{"desc": "d", "run": "r"}]},
{"id": "b", "title": "t", "tier": "code", "brief": "b", "deps": ["a"], "files_touched": ["y"], "checks": [{"desc": "d", "run": "r"}]},
{"id": "c", "title": "t", "tier": "code", "brief": "b", "deps": ["b"], "files_touched": ["z"], "checks": [{"desc": "d", "run": "r"}]},
],
});
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::DependencyCycle { .. }
));
}
#[test]
fn diamond_dag_is_acyclic() {
let v = json!({
"schema_version": 3, "plan_rev": 1, "intent_rev": 1,
"feature": {"slug": "f", "source_branch": "main", "integration_branch": "feat/f"},
"baseline": minimal_baseline(),
"acceptance": [{"kind": "check", "desc": "e2e", "run": "t"}],
"chunks": [
{"id": "a", "title": "t", "tier": "code", "brief": "b", "files_touched": ["w"], "checks": [{"desc": "d", "run": "r"}]},
{"id": "b", "title": "t", "tier": "code", "brief": "b", "deps": ["a"], "files_touched": ["x"], "checks": [{"desc": "d", "run": "r"}]},
{"id": "c", "title": "t", "tier": "code", "brief": "b", "deps": ["a"], "files_touched": ["y"], "checks": [{"desc": "d", "run": "r"}]},
{"id": "d", "title": "t", "tier": "code", "brief": "b", "deps": ["b", "c"], "files_touched": ["z"], "checks": [{"desc": "d", "run": "r"}]},
],
});
assert!(parse_and_validate_plan(&v).is_ok());
}
#[test]
fn path_traversal_in_files_touched_rejected() {
for bad in [
"../etc/passwd",
"/abs/path",
"~/secret",
"a/../b",
"a//b",
"a\\b",
] {
let mut v = valid_plan();
v["chunks"][0]["files_touched"] = json!([bad]);
assert!(
matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnsafePath { .. }
),
"expected UnsafePath for {bad:?}"
);
}
}
#[test]
fn safe_paths_accepted() {
for ok in [
"src/a.rs",
"crates/x/src/mod.rs",
"a.rs",
"deep/nested/dir/file.txt",
".github/workflows/ci.yml", ] {
assert!(is_safe_repo_relative(ok), "should accept {ok:?}");
}
for bad in [
"", "/x", "~/x", "..", "a/../b", "a//b", "a\\b", "a/./b", ".", "C:/Windows", "C:foo", "src/foo\nbar", "src/foo\tbar", " ", "a/ /b", ] {
assert!(!is_safe_repo_relative(bad), "should reject {bad:?}");
}
}
#[test]
fn empty_feature_slug_rejected() {
let mut v = valid_plan();
v["feature"]["slug"] = json!(" ");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::EmptyString { path } if path == "feature.slug"
));
}
#[test]
fn empty_baseline_hash_rejected() {
let mut v = valid_plan();
v["baseline"]["test_passlist_hash"] = json!("");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::EmptyString { path } if path == "baseline.test_passlist_hash"
));
}
#[test]
fn missing_provenance_field_is_malformed() {
for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
let mut v = valid_plan();
v["baseline"].as_object_mut().unwrap().remove(field);
let err = parse_and_validate_plan(&v).unwrap_err();
assert!(
matches!(err, PlanValidationError::Malformed { .. }),
"expected Malformed for missing baseline.{field}, got {err:?}"
);
}
}
#[test]
fn all_provenance_fields_missing_is_malformed() {
let mut v = valid_plan();
for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
v["baseline"].as_object_mut().unwrap().remove(field);
}
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::Malformed { .. }
));
}
#[test]
fn empty_provenance_field_rejected() {
for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
let mut v = valid_plan();
v["baseline"][field] = json!(" ");
let err = parse_and_validate_plan(&v).unwrap_err();
assert!(
matches!(&err, PlanValidationError::EmptyString { path } if path == &format!("baseline.{field}")),
"expected EmptyString for blank baseline.{field}, got {err:?}"
);
}
}
#[test]
fn provenance_gate_fires_via_validate_plan_on_typed_blank() {
let mut plan = parse_and_validate_plan(&valid_plan()).unwrap();
plan.baseline.commit_oid = String::new();
assert!(matches!(
validate_plan(&plan).unwrap_err(),
PlanValidationError::EmptyString { path } if path == "baseline.commit_oid"
));
}
#[test]
fn provenance_fields_always_serialize() {
let plan = parse_and_validate_plan(&valid_plan()).unwrap();
let reser = serde_json::to_value(&plan).unwrap();
let baseline = reser["baseline"].as_object().unwrap();
for field in ["commit_oid", "toolchain", "enumerated_targets_hash"] {
assert!(
baseline.contains_key(field),
"baseline must serialize {field}"
);
}
}
#[test]
fn tier_wire_names_round_trip() {
for &name in Tier::WIRE_NAMES {
let tier: Tier = serde_json::from_value(json!(name)).unwrap();
assert_eq!(serde_json::to_value(tier).unwrap(), json!(name));
}
}
#[test]
fn json_schema_matches_rust_types() {
let schema: Value = serde_json::from_str(PLAN_V3_JSON_SCHEMA)
.expect("checked-in JSON Schema must be valid JSON");
assert_eq!(
schema["properties"]["schema_version"]["const"],
json!(PLAN_SCHEMA_VERSION)
);
let required: HashSet<String> = schema["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
let expected: HashSet<String> = [
"schema_version",
"plan_rev",
"intent_rev",
"feature",
"baseline",
"acceptance",
"chunks",
]
.iter()
.map(ToString::to_string)
.collect();
assert_eq!(required, expected);
let tiers: Vec<String> = schema["$defs"]["chunk"]["properties"]["tier"]["enum"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(tiers, Tier::WIRE_NAMES);
let required_at = |ptr: &str| -> HashSet<String> {
schema
.pointer(ptr)
.and_then(Value::as_array)
.unwrap_or_else(|| panic!("missing required[] at {ptr}"))
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
};
let set = |fields: &[&str]| -> HashSet<String> {
fields.iter().map(ToString::to_string).collect()
};
assert_eq!(
required_at("/properties/feature/required"),
set(&["slug", "source_branch", "integration_branch"])
);
assert_eq!(
required_at("/properties/baseline/required"),
set(&[
"ref",
"commit_oid",
"toolchain",
"test_passlist_hash",
"clippy_warnings_hash",
"enumerated_targets_hash",
])
);
for field in [
"ref",
"commit_oid",
"toolchain",
"test_passlist_hash",
"clippy_warnings_hash",
"enumerated_targets_hash",
] {
assert_eq!(
schema.pointer(&format!(
"/properties/baseline/properties/{field}/minLength"
)),
Some(&json!(1)),
"expected baseline.{field} minLength:1 in the JSON Schema"
);
}
assert_eq!(
required_at("/$defs/chunk/required"),
set(&["id", "title", "tier", "brief", "files_touched", "checks"])
);
assert_eq!(required_at("/$defs/check/required"), set(&["desc", "run"]));
assert_eq!(
required_at("/$defs/acceptance_item/oneOf/0/required"),
set(&["kind", "desc", "run"])
);
assert_eq!(
required_at("/$defs/acceptance_item/oneOf/1/required"),
set(&["kind", "desc"])
);
for ptr in [
"/$defs/check/properties",
"/$defs/acceptance_item/oneOf/0/properties",
] {
let props = schema
.pointer(ptr)
.unwrap_or_else(|| panic!("missing {ptr}"));
assert_eq!(
props["cwd"]["type"],
json!("string"),
"expected optional string `cwd` at {ptr}"
);
assert_eq!(
props["cwd"]["minLength"],
json!(1),
"expected `cwd` minLength:1 at {ptr}"
);
assert_eq!(
props["expect_exit"]["type"],
json!("integer"),
"expected optional integer `expect_exit` at {ptr}"
);
assert_eq!(
props["expect_exit"]["minimum"],
json!(0),
"expected `expect_exit` minimum:0 at {ptr}"
);
assert_eq!(
props["expect_exit"]["maximum"],
json!(i64::from(MAX_SHELL_EXIT)),
"expected `expect_exit` maximum:255 at {ptr}"
);
}
for ptr in [
"",
"/properties/feature",
"/properties/baseline",
"/$defs/chunk",
"/$defs/check",
"/$defs/acceptance_item/oneOf/0",
"/$defs/acceptance_item/oneOf/1",
] {
let node = if ptr.is_empty() {
&schema
} else {
schema
.pointer(ptr)
.unwrap_or_else(|| panic!("missing {ptr}"))
};
assert_eq!(
node["additionalProperties"],
json!(false),
"expected additionalProperties:false at {ptr:?}"
);
}
let kinds: HashSet<String> = schema["$defs"]["acceptance_item"]["oneOf"]
.as_array()
.unwrap()
.iter()
.map(|variant| {
variant["properties"]["kind"]["const"]
.as_str()
.unwrap()
.to_string()
})
.collect();
assert_eq!(kinds, set(&["check", "assertion"]));
let example: Value = serde_json::from_str(PLAN_V3_EXAMPLE).unwrap();
assert!(parse_and_validate_plan(&example).is_ok());
}
#[test]
fn tolerated_optional_seam_is_empty_in_v3() {
for shape in [
ObjectShape::Plan,
ObjectShape::Feature,
ObjectShape::Baseline,
ObjectShape::Chunk,
ObjectShape::Check,
] {
assert!(tolerated_fields(shape).is_empty());
}
assert!(TOLERATED_OPTIONAL_FIELDS.is_empty());
}
#[test]
fn unknown_field_scoped_to_its_object() {
let mut v = valid_plan();
v["feature"]["team"] = json!("payments");
assert!(matches!(
parse_and_validate_plan(&v).unwrap_err(),
PlanValidationError::UnknownField { path, field }
if path == "feature" && field == "team"
));
}
}