Skip to main content

agent_fleet/
config.rs

1//! fleet.yaml parser + strict validator (SPEC ยง2.1, C5).
2
3use std::collections::BTreeSet;
4use std::path::Path;
5
6use regex::Regex;
7use serde::Deserialize;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11pub enum FleetConfigError {
12    #[error("failed to read or parse {path}: {source}")]
13    Io {
14        path: String,
15        #[source]
16        source: std::io::Error,
17    },
18    #[error("failed to read or parse {path}: {message}")]
19    Parse { path: String, message: String },
20    #[error("{0}")]
21    Validation(String),
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct FleetEntry {
26    pub name: String,
27    pub repo: String,
28    pub path: String,
29    pub template: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct FleetConfig {
34    pub fleet: Vec<FleetEntry>,
35}
36
37/// Allowed top-level keys.
38const ROOT_KEYS: &[&str] = &["fleet"];
39/// Allowed per-entry keys.
40const ENTRY_KEYS: &[&str] = &["name", "repo", "path", "template"];
41const VALID_TEMPLATES: &[&str] = &["typescript-bun"];
42
43fn name_re() -> Regex {
44    Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("name regex compiles")
45}
46fn repo_re() -> Regex {
47    Regex::new(r"^[^/]+/[^/]+$").expect("repo regex compiles")
48}
49
50pub fn load_fleet_config<P: AsRef<Path>>(path: P) -> Result<FleetConfig, FleetConfigError> {
51    let p = path.as_ref();
52    let p_str = p.display().to_string();
53    let text = std::fs::read_to_string(p).map_err(|e| FleetConfigError::Io {
54        path: p_str.clone(),
55        source: e,
56    })?;
57    let raw: serde_yaml::Value =
58        serde_yaml::from_str(&text).map_err(|e| FleetConfigError::Parse {
59            path: p_str.clone(),
60            message: e.to_string(),
61        })?;
62    validate(raw)
63}
64
65fn validate(raw: serde_yaml::Value) -> Result<FleetConfig, FleetConfigError> {
66    let map = match raw {
67        serde_yaml::Value::Mapping(m) => m,
68        _ => {
69            return Err(FleetConfigError::Validation(
70                "invalid fleet.yaml: root must be a mapping".into(),
71            ))
72        }
73    };
74
75    let mut top_keys: BTreeSet<String> = BTreeSet::new();
76    for (k, _) in map.iter() {
77        if let Some(s) = k.as_str() {
78            top_keys.insert(s.to_string());
79        }
80    }
81    let allowed_top: BTreeSet<String> = ROOT_KEYS.iter().map(|s| s.to_string()).collect();
82    let extra: Vec<String> = top_keys.difference(&allowed_top).cloned().collect();
83    if !extra.is_empty() {
84        return Err(FleetConfigError::Validation(format!(
85            "invalid fleet.yaml: unrecognized extra key(s): {}",
86            extra.join(", ")
87        )));
88    }
89    if !top_keys.contains("fleet") {
90        return Err(FleetConfigError::Validation(
91            "invalid fleet.yaml: missing required key 'fleet'".into(),
92        ));
93    }
94    let fleet_v = map.get("fleet").ok_or_else(|| {
95        FleetConfigError::Validation("invalid fleet.yaml: missing required key 'fleet'".into())
96    })?;
97    let seq = match fleet_v {
98        serde_yaml::Value::Sequence(s) => s,
99        _ => {
100            return Err(FleetConfigError::Validation(
101                "invalid fleet.yaml: 'fleet' must be a list".into(),
102            ))
103        }
104    };
105    if seq.is_empty() {
106        return Err(FleetConfigError::Validation(
107            "invalid fleet.yaml: fleet must contain at least one entry".into(),
108        ));
109    }
110    let mut entries = Vec::with_capacity(seq.len());
111    for (i, item) in seq.iter().enumerate() {
112        entries.push(validate_entry(item, i)?);
113    }
114    Ok(FleetConfig { fleet: entries })
115}
116
117fn validate_entry(item: &serde_yaml::Value, index: usize) -> Result<FleetEntry, FleetConfigError> {
118    let map = match item {
119        serde_yaml::Value::Mapping(m) => m,
120        _ => {
121            return Err(FleetConfigError::Validation(format!(
122                "fleet.{}: entry must be a mapping",
123                index
124            )))
125        }
126    };
127    let mut keys: BTreeSet<String> = BTreeSet::new();
128    for (k, _) in map.iter() {
129        if let Some(s) = k.as_str() {
130            keys.insert(s.to_string());
131        }
132    }
133    let allowed: BTreeSet<String> = ENTRY_KEYS.iter().map(|s| s.to_string()).collect();
134    let missing: Vec<String> = allowed.difference(&keys).cloned().collect();
135    if !missing.is_empty() {
136        return Err(FleetConfigError::Validation(format!(
137            "fleet.{}: missing required field(s): {}",
138            index,
139            missing.join(", ")
140        )));
141    }
142    let extra: Vec<String> = keys.difference(&allowed).cloned().collect();
143    if !extra.is_empty() {
144        return Err(FleetConfigError::Validation(format!(
145            "fleet.{}: unrecognized extra field(s): {}",
146            index,
147            extra.join(", ")
148        )));
149    }
150
151    fn get_str<'a>(
152        m: &'a serde_yaml::Mapping,
153        key: &str,
154    ) -> Option<&'a str> {
155        m.get(key).and_then(|v| v.as_str())
156    }
157
158    let name = get_str(map, "name").ok_or_else(|| {
159        FleetConfigError::Validation(format!("fleet.{}.name: must be a string", index))
160    })?;
161    let repo = get_str(map, "repo").ok_or_else(|| {
162        FleetConfigError::Validation(format!("fleet.{}.repo: must be a string", index))
163    })?;
164    let path = get_str(map, "path").ok_or_else(|| {
165        FleetConfigError::Validation(format!("fleet.{}.path: must be a string", index))
166    })?;
167    let template = get_str(map, "template").ok_or_else(|| {
168        FleetConfigError::Validation(format!("fleet.{}.template: must be a string", index))
169    })?;
170
171    if !name_re().is_match(name) {
172        return Err(FleetConfigError::Validation(format!(
173            "fleet.{}.name: name must match ^[a-z0-9][a-z0-9-]*$",
174            index
175        )));
176    }
177    if !repo_re().is_match(repo) {
178        return Err(FleetConfigError::Validation(format!(
179            r#"fleet.{}.repo: repo must be "owner/name""#,
180            index
181        )));
182    }
183    if path.is_empty() {
184        return Err(FleetConfigError::Validation(format!(
185            "fleet.{}.path: path must be a non-empty string",
186            index
187        )));
188    }
189    if !VALID_TEMPLATES.contains(&template) {
190        return Err(FleetConfigError::Validation(format!(
191            "fleet.{}.template: template must be one of {:?}",
192            index, VALID_TEMPLATES
193        )));
194    }
195
196    Ok(FleetEntry {
197        name: name.to_string(),
198        repo: repo.to_string(),
199        path: path.to_string(),
200        template: template.to_string(),
201    })
202}
203
204// Allow serde derive in case downstream callers want it; not used internally.
205#[derive(Debug, Deserialize)]
206#[serde(deny_unknown_fields)]
207#[allow(dead_code)]
208struct _SchemaShape {
209    fleet: Vec<_EntryShape>,
210}
211
212#[derive(Debug, Deserialize)]
213#[serde(deny_unknown_fields)]
214#[allow(dead_code)]
215struct _EntryShape {
216    name: String,
217    repo: String,
218    path: String,
219    template: String,
220}