use crate::config::error::LoadError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct Manifest {
#[serde(default)]
pub roles: BTreeMap<String, RoleRules>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct RoleRules {
#[serde(default)]
pub pinned: Vec<String>,
#[serde(default)]
pub order: Vec<String>,
pub budget_tokens: u32,
pub overflow: OverflowPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OverflowPolicy {
DropOldestSummaries,
Truncate,
Drop,
}
impl Manifest {
pub fn parse(raw: &str, origin: &Path) -> Result<Self, LoadError> {
let parsed: Self = serde_yaml_ng::from_str(raw).map_err(|source| LoadError::Yaml {
path: origin.to_path_buf(),
source,
})?;
parsed.validate(origin)?;
Ok(parsed)
}
fn validate(&self, path: &Path) -> Result<(), LoadError> {
for (role, rules) in &self.roles {
for (i, p) in rules.pinned.iter().enumerate() {
check_path_shape(path, &format!("roles.{role}.pinned[{i}]"), p)?;
}
for (i, g) in rules.order.iter().enumerate() {
check_path_shape(path, &format!("roles.{role}.order[{i}]"), g)?;
}
if rules.budget_tokens == 0 {
return Err(LoadError::Invalid {
path: path.to_path_buf(),
key: format!("roles.{role}.budget_tokens"),
message: "must be positive".into(),
});
}
}
Ok(())
}
}
fn check_path_shape(file: &Path, key: &str, value: &str) -> Result<(), LoadError> {
if value.is_empty() {
return Err(LoadError::Invalid {
path: file.to_path_buf(),
key: key.into(),
message: "must not be empty".into(),
});
}
if value.starts_with('/') {
return Err(LoadError::Invalid {
path: file.to_path_buf(),
key: key.into(),
message: format!("must be relative to the branch worktree, got {value:?}"),
});
}
if value.split('/').any(|seg| seg == "..") {
return Err(LoadError::Invalid {
path: file.to_path_buf(),
key: key.into(),
message: format!("may not contain '..', got {value:?}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Result<Manifest, LoadError> {
Manifest::parse(s, Path::new("deadbeef:manifest.yaml"))
}
const ARCH_EXAMPLE: &str = r#"
roles:
worker:
pinned:
- goal.md
- soul.md
- descriptions/**
order:
- summary/**
- skills/**
budget_tokens: 150000
overflow: drop_oldest_summaries
compactor:
pinned:
- goal.md
- soul.md
order:
- summary/**
budget_tokens: 50000
overflow: truncate
"#;
#[test]
fn parses_arch_example() {
let m = parse(ARCH_EXAMPLE).unwrap();
assert_eq!(m.roles.len(), 2);
let worker = &m.roles["worker"];
assert_eq!(worker.budget_tokens, 150_000);
assert_eq!(worker.overflow, OverflowPolicy::DropOldestSummaries);
assert_eq!(worker.pinned.len(), 3);
assert_eq!(worker.order, vec!["summary/**", "skills/**"]);
let compactor = &m.roles["compactor"];
assert_eq!(compactor.overflow, OverflowPolicy::Truncate);
assert_eq!(compactor.order, vec!["summary/**"]);
}
#[test]
fn accepts_each_overflow_variant() {
for variant in ["drop_oldest_summaries", "truncate", "drop"] {
let yaml = format!(
"roles:\n r:\n pinned: []\n order: []\n budget_tokens: 1\n overflow: {variant}\n"
);
assert!(parse(&yaml).is_ok(), "variant {variant} failed");
}
}
#[test]
fn rejects_retired_summarize_overflow() {
let err = parse(
"roles:\n r:\n pinned: []\n order: []\n budget_tokens: 1\n overflow: summarize\n",
)
.unwrap_err();
match err {
LoadError::Yaml { source, .. } => {
assert!(source.to_string().contains("summarize"), "{source}")
}
other => panic!("expected Yaml, got {other:?}"),
}
}
#[test]
fn rejects_retired_drop_oldest_steps_overflow() {
let err = parse(
"roles:\n r:\n pinned: []\n order: []\n budget_tokens: 1\n overflow: drop_oldest_steps\n",
)
.unwrap_err();
match err {
LoadError::Yaml { source, .. } => {
let msg = source.to_string();
assert!(msg.contains("drop_oldest_steps"), "{msg}");
assert!(msg.contains("drop_oldest_summaries"), "{msg}");
}
other => panic!("expected Yaml, got {other:?}"),
}
}
#[test]
fn empty_roles_section_is_ok() {
let m = parse("roles: {}\n").unwrap();
assert!(m.roles.is_empty());
}
#[test]
fn missing_roles_section_loads_empty() {
let m = parse("# nothing yet\n").unwrap();
assert!(m.roles.is_empty());
}
#[test]
fn rejects_absolute_pinned_path() {
let err = parse(
"roles:\n r:\n pinned: [/etc/secret]\n order: []\n budget_tokens: 1\n overflow: drop\n",
)
.unwrap_err();
match err {
LoadError::Invalid { key, .. } => assert_eq!(key, "roles.r.pinned[0]"),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_parent_dir_in_order() {
let err = parse(
"roles:\n r:\n pinned: []\n order: [\"../escape/**\"]\n budget_tokens: 1\n overflow: drop\n",
)
.unwrap_err();
match err {
LoadError::Invalid { key, message, .. } => {
assert_eq!(key, "roles.r.order[0]");
assert!(message.contains(".."));
}
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn rejects_empty_path() {
let err = parse(
"roles:\n r:\n pinned: [\"\"]\n order: []\n budget_tokens: 1\n overflow: drop\n",
)
.unwrap_err();
assert!(matches!(err, LoadError::Invalid { .. }));
}
#[test]
fn rejects_zero_budget() {
let err = parse(
"roles:\n r:\n pinned: []\n order: []\n budget_tokens: 0\n overflow: drop\n",
)
.unwrap_err();
match err {
LoadError::Invalid { key, .. } => assert_eq!(key, "roles.r.budget_tokens"),
other => panic!("expected Invalid, got {other:?}"),
}
}
#[test]
fn surfaces_yaml_errors_with_the_origin() {
match parse("not yaml: [").unwrap_err() {
LoadError::Yaml { path, .. } => {
assert_eq!(path, Path::new("deadbeef:manifest.yaml"));
}
other => panic!("expected Yaml, got {other:?}"),
}
}
}