use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Scenario {
pub input: String,
pub expect: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeclarativeAgentSpec {
pub id: String,
pub name: String,
pub identity: String,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub denied_tools: Vec<String>,
#[serde(default)]
pub standing_goal: String,
#[serde(default)]
pub scenarios: Vec<Scenario>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
impl DeclarativeAgentSpec {
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
if !is_filename_safe(&self.id) {
issues.push(format!(
"invalid agent id (alphanumeric + -_.): {:?}",
self.id
));
}
if self.name.trim().is_empty() {
issues.push("agent name is empty".into());
}
if self.identity.trim().is_empty() {
issues.push("agent identity (system prompt) is empty".into());
}
if self.scenarios.is_empty() {
issues.push("agent must have at least one acceptance scenario".into());
}
issues
}
}
fn is_filename_safe(id: &str) -> bool {
!id.is_empty()
&& id != "."
&& id != ".."
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
pub struct DeclRegistry {
path: PathBuf,
}
impl DeclRegistry {
pub fn user_default() -> Result<Self, String> {
if let Some(p) = std::env::var_os("CAR_DECLAGENTS_PATH") {
return Ok(Self {
path: PathBuf::from(p),
});
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or("cannot resolve home directory (HOME/USERPROFILE unset)")?;
Ok(Self {
path: PathBuf::from(home).join(".car").join("declagents.json"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
fn read_all(&self) -> Vec<DeclarativeAgentSpec> {
std::fs::read_to_string(&self.path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_all(&self, specs: &[DeclarativeAgentSpec]) -> Result<(), String> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let json = serde_json::to_string_pretty(specs).map_err(|e| e.to_string())?;
let tmp = self.path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, &self.path)
.map_err(|e| format!("rename into {}: {e}", self.path.display()))
}
pub fn upsert(&self, spec: DeclarativeAgentSpec) -> Result<(), String> {
let issues = spec.validate();
if !issues.is_empty() {
return Err(format!("invalid declarative agent: {}", issues.join("; ")));
}
let mut all = self.read_all();
if let Some(existing) = all.iter_mut().find(|s| s.id == spec.id) {
*existing = spec;
} else {
all.push(spec);
}
self.write_all(&all)
}
pub fn list(&self) -> Vec<DeclarativeAgentSpec> {
self.read_all()
}
pub fn get(&self, id: &str) -> Option<DeclarativeAgentSpec> {
self.read_all().into_iter().find(|s| s.id == id)
}
pub fn remove(&self, id: &str) -> Result<bool, String> {
let mut all = self.read_all();
let before = all.len();
all.retain(|s| s.id != id);
let removed = all.len() != before;
if removed {
self.write_all(&all)?;
}
Ok(removed)
}
pub fn set_enabled(&self, id: &str, on: bool) -> Result<(), String> {
let mut all = self.read_all();
let spec = all
.iter_mut()
.find(|s| s.id == id)
.ok_or_else(|| format!("no declarative agent '{id}'"))?;
spec.enabled = on;
self.write_all(&all)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(id: &str) -> DeclarativeAgentSpec {
DeclarativeAgentSpec {
id: id.into(),
name: "Test".into(),
identity: "You are a test agent.".into(),
tools: vec!["read_file".into()],
denied_tools: vec![],
standing_goal: "be helpful".into(),
scenarios: vec![Scenario {
input: "hi".into(),
expect: "ok".into(),
}],
enabled: true,
}
}
fn temp_registry() -> (tempfile::TempDir, DeclRegistry) {
let dir = tempfile::tempdir().unwrap();
let reg = DeclRegistry::at(dir.path().join("declagents.json"));
(dir, reg)
}
#[test]
fn upsert_rejects_spec_with_no_scenarios() {
let (_d, reg) = temp_registry();
let mut s = spec("no-scenarios");
s.scenarios.clear();
let err = reg
.upsert(s)
.expect_err("zero-scenario spec must be rejected");
assert!(
err.contains("at least one acceptance scenario"),
"got: {err}"
);
}
#[test]
fn upsert_list_get_remove_round_trip() {
let (_d, reg) = temp_registry();
reg.upsert(spec("email-bot")).unwrap();
reg.upsert(spec("note-taker")).unwrap();
assert_eq!(reg.list().len(), 2);
assert_eq!(reg.get("email-bot").unwrap().name, "Test");
let mut updated = spec("email-bot");
updated.name = "Renamed".into();
reg.upsert(updated).unwrap();
assert_eq!(reg.list().len(), 2);
assert_eq!(reg.get("email-bot").unwrap().name, "Renamed");
assert!(reg.remove("email-bot").unwrap());
assert!(!reg.remove("email-bot").unwrap());
assert_eq!(reg.list().len(), 1);
}
#[test]
fn set_enabled_toggles() {
let (_d, reg) = temp_registry();
reg.upsert(spec("a")).unwrap();
reg.set_enabled("a", false).unwrap();
assert!(!reg.get("a").unwrap().enabled);
reg.set_enabled("a", true).unwrap();
assert!(reg.get("a").unwrap().enabled);
assert!(reg.set_enabled("missing", true).is_err());
}
#[test]
fn invalid_spec_is_rejected() {
let (_d, reg) = temp_registry();
let mut bad = spec("../escape");
assert!(reg.upsert(bad.clone()).is_err());
bad.id = "ok".into();
bad.identity = " ".into();
assert!(reg.upsert(bad).is_err());
}
#[test]
fn persists_across_handles() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("declagents.json");
DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
assert!(DeclRegistry::at(&path).get("persist").is_some());
}
}