codewhale_workflow/
named_fleet.rs1use 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 parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
64 let mut name = None;
65 let mut description = None;
66 let mut roles = BTreeMap::new();
67 let mut section = "";
68 for raw in text.lines() {
69 let line = raw.split('#').next().unwrap_or("").trim();
70 if line.is_empty() {
71 continue;
72 }
73 if line.starts_with('[') && line.ends_with(']') {
74 section = &line[1..line.len() - 1];
75 continue;
76 }
77 let Some((key, value)) = line.split_once('=') else {
78 continue;
79 };
80 let key = key.trim();
81 let value = value.trim().trim_matches('"').to_string();
82 match section {
83 "" => match key {
84 "name" => name = Some(value),
85 "description" => description = Some(value),
86 _ => {}
87 },
88 "roles" => {
89 roles.insert(key.to_string(), value);
90 }
91 _ => {}
92 }
93 }
94 let name = name.ok_or_else(|| NamedFleetError::Parse {
95 path: "<memory>".into(),
96 message: "missing name".into(),
97 })?;
98 Ok(NamedFleet {
99 name,
100 description,
101 roles,
102 })
103}
104
105pub fn load_named_fleet(
107 name: &str,
108 search_roots: &[PathBuf],
109) -> Result<NamedFleet, NamedFleetError> {
110 let file_name = format!("{name}.toml");
111 for root in search_roots {
112 let path = root.join("fleets").join(&file_name);
113 if path.is_file() {
114 return load_named_fleet_file(&path, Some(name));
115 }
116 }
117 Err(NamedFleetError::NotFound(name.to_string()))
118}
119
120pub fn load_named_fleet_file(
121 path: &Path,
122 expect_name: Option<&str>,
123) -> Result<NamedFleet, NamedFleetError> {
124 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
125 path: path.display().to_string(),
126 message: e.to_string(),
127 })?;
128 let fleet = parse_named_fleet(&text).map_err(|e| match e {
129 NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
130 path: path.display().to_string(),
131 message,
132 },
133 other => other,
134 })?;
135 if let Some(expected) = expect_name
136 && fleet.name != expected
137 {
138 return Err(NamedFleetError::NameMismatch {
139 declared: fleet.name,
140 expected: expected.to_string(),
141 });
142 }
143 Ok(fleet)
144}
145
146impl NamedFleet {
147 pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
149 let key = role.trim().to_ascii_lowercase();
150 self.roles
151 .get(&key)
152 .or_else(|| {
153 self.roles
154 .iter()
155 .find(|(k, _)| k.eq_ignore_ascii_case(role))
156 .map(|(_, v)| v)
157 })
158 .map(String::as_str)
159 .ok_or_else(|| NamedFleetError::MissingRole {
160 fleet: self.name.clone(),
161 role: role.to_string(),
162 })
163 }
164
165 pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
167 for role in STOPSHIP_REQUIRED_ROLES {
168 self.resolve(role)?;
169 }
170 Ok(())
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 const STOPSHIP_TOML: &str = r#"
179name = "stopship"
180description = "Stopship dogfood fleet"
181
182[roles]
183scout = "scout"
184implementer = "builder"
185reviewer = "reviewer"
186verifier = "verifier"
187release_lead = "manager"
188"#;
189
190 #[test]
191 fn stopship_fleet_resolves_all_five_roles() {
192 let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
193 assert_eq!(fleet.name, "stopship");
194 fleet.validate_stopship_roles().expect("all roles");
195 assert_eq!(fleet.resolve("scout").unwrap(), "scout");
196 assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
197 assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
198 assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
199 assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
200 }
201
202 #[test]
203 fn unknown_role_fails_clearly() {
204 let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
205 let err = fleet.resolve("wizard").unwrap_err();
206 assert!(matches!(err, NamedFleetError::MissingRole { .. }));
207 }
208
209 #[test]
210 fn loads_workspace_fleet_file() {
211 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
213 .join("..")
214 .join("..");
215 let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
216 fleet.validate_stopship_roles().unwrap();
217 }
218}