1use 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
37const ROOT_KEYS: &[&str] = &["fleet"];
39const 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#[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}