1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct NamedFleet {
17 pub name: String,
18 #[serde(default)]
19 pub description: Option<String>,
20 pub roles: BTreeMap<String, String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Error)]
25pub enum NamedFleetError {
26 #[error("fleet file not found: {0}")]
27 NotFound(String),
28 #[error("failed to read fleet file {path}: {message}")]
29 Io { path: String, message: String },
30 #[error("failed to parse fleet file {path}: {message}")]
31 Parse { path: String, message: String },
32 #[error("fleet `{fleet}` is missing required role `{role}`")]
33 MissingRole { fleet: String, role: String },
34 #[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
35 NameMismatch { declared: String, expected: String },
36}
37
38pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[
40 "scout",
41 "implementer",
42 "reviewer",
43 "verifier",
44 "release_lead",
45];
46
47pub fn parse_named_fleet(toml_text: &str) -> Result<NamedFleet, NamedFleetError> {
49 let trimmed = toml_text.trim();
54 if trimmed.starts_with('{') {
55 return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse {
56 path: "<memory>".into(),
57 message: e.to_string(),
58 });
59 }
60 parse_fleet_toml_minimal(trimmed)
61}
62
63fn strip_toml_comment(line: &str) -> &str {
66 let mut quote = None;
67 let mut escaped = false;
68
69 for (index, character) in line.char_indices() {
70 match quote {
71 Some('"') => {
72 if escaped {
73 escaped = false;
74 } else {
75 match character {
76 '\\' => escaped = true,
77 '"' => quote = None,
78 _ => {}
79 }
80 }
81 }
82 Some('\'') => {
83 if character == '\'' {
84 quote = None;
85 }
86 }
87 Some(_) => unreachable!("only TOML string delimiters are tracked"),
88 None => match character {
89 '"' | '\'' => quote = Some(character),
90 '#' => return &line[..index],
91 _ => {}
92 },
93 }
94 }
95
96 line
97}
98
99fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
100 let mut name = None;
101 let mut description = None;
102 let mut roles = BTreeMap::new();
103 let mut section = "";
104 for raw in text.lines() {
105 let line = strip_toml_comment(raw).trim();
106 if line.is_empty() {
107 continue;
108 }
109 if line.starts_with('[') && line.ends_with(']') {
110 section = &line[1..line.len() - 1];
111 continue;
112 }
113 let Some((key, value)) = line.split_once('=') else {
114 continue;
115 };
116 let key = key.trim();
117 let value = value.trim().trim_matches('"').to_string();
118 match section {
119 "" => match key {
120 "name" => name = Some(value),
121 "description" => description = Some(value),
122 _ => {}
123 },
124 "roles" => {
125 roles.insert(key.to_string(), value);
126 }
127 _ => {}
128 }
129 }
130 let name = name.ok_or_else(|| NamedFleetError::Parse {
131 path: "<memory>".into(),
132 message: "missing name".into(),
133 })?;
134 Ok(NamedFleet {
135 name,
136 description,
137 roles,
138 })
139}
140
141pub fn load_named_fleet(
143 name: &str,
144 search_roots: &[PathBuf],
145) -> Result<NamedFleet, NamedFleetError> {
146 let file_name = format!("{name}.toml");
147 for root in search_roots {
148 let path = root.join("fleets").join(&file_name);
149 if path.is_file() {
150 return load_named_fleet_file(&path, Some(name));
151 }
152 }
153 Err(NamedFleetError::NotFound(name.to_string()))
154}
155
156pub fn load_named_fleet_file(
157 path: &Path,
158 expect_name: Option<&str>,
159) -> Result<NamedFleet, NamedFleetError> {
160 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
161 path: path.display().to_string(),
162 message: e.to_string(),
163 })?;
164 let fleet = parse_named_fleet(&text).map_err(|e| match e {
165 NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
166 path: path.display().to_string(),
167 message,
168 },
169 other => other,
170 })?;
171 if let Some(expected) = expect_name
172 && fleet.name != expected
173 {
174 return Err(NamedFleetError::NameMismatch {
175 declared: fleet.name,
176 expected: expected.to_string(),
177 });
178 }
179 Ok(fleet)
180}
181
182impl NamedFleet {
183 pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
185 let key = role.trim().to_ascii_lowercase();
186 self.roles
187 .get(&key)
188 .or_else(|| {
189 self.roles
190 .iter()
191 .find(|(k, _)| k.eq_ignore_ascii_case(role))
192 .map(|(_, v)| v)
193 })
194 .map(String::as_str)
195 .ok_or_else(|| NamedFleetError::MissingRole {
196 fleet: self.name.clone(),
197 role: role.to_string(),
198 })
199 }
200
201 pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
203 for role in STOPSHIP_REQUIRED_ROLES {
204 self.resolve(role)?;
205 }
206 Ok(())
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 const STOPSHIP_TOML: &str = r#"
215name = "stopship"
216description = "Stopship dogfood fleet"
217
218[roles]
219scout = "scout"
220implementer = "builder"
221reviewer = "reviewer"
222verifier = "verifier"
223release_lead = "manager"
224"#;
225
226 #[test]
227 fn stopship_fleet_resolves_all_five_roles() {
228 let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
229 assert_eq!(fleet.name, "stopship");
230 fleet.validate_stopship_roles().expect("all roles");
231 assert_eq!(fleet.resolve("scout").unwrap(), "scout");
232 assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
233 assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
234 assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
235 assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
236 }
237
238 #[test]
239 fn unknown_role_fails_clearly() {
240 let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
241 let err = fleet.resolve("wizard").unwrap_err();
242 assert!(matches!(err, NamedFleetError::MissingRole { .. }));
243 }
244
245 #[test]
246 fn quoted_hashes_are_not_treated_as_comments() {
247 let fleet = parse_named_fleet(
248 r#"
249name = "issue-references"
250description = "Tracks #4178 dogfood" # real comment
251
252[roles]
253scout = "scout#stable"
254"#,
255 )
256 .expect("parse");
257
258 assert_eq!(fleet.description.as_deref(), Some("Tracks #4178 dogfood"));
259 assert_eq!(fleet.resolve("scout").unwrap(), "scout#stable");
260 }
261
262 #[test]
263 fn comment_stripping_tracks_toml_quotes_and_escapes() {
264 assert_eq!(
265 strip_toml_comment(r##"description = "say \"#still-value\"" # comment"##).trim_end(),
266 r##"description = "say \"#still-value\"""##
267 );
268 assert_eq!(
269 strip_toml_comment("description = 'tracks #4178' # comment").trim_end(),
270 "description = 'tracks #4178'"
271 );
272 assert_eq!(
273 strip_toml_comment(r#"name = "stopship" # comment"#).trim_end(),
274 r#"name = "stopship""#
275 );
276 }
277
278 #[test]
279 fn loads_workspace_fleet_file() {
280 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
282 .join("..")
283 .join("..");
284 let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
285 fleet.validate_stopship_roles().unwrap();
286 }
287}