1use std::collections::BTreeMap;
18
19use thiserror::Error;
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct FleetRoleMap {
27 roles: BTreeMap<String, String>,
29}
30
31impl FleetRoleMap {
32 pub fn new() -> Self {
33 Self::default()
34 }
35
36 pub fn insert(
38 &mut self,
39 role: impl Into<String>,
40 profile: impl Into<String>,
41 ) -> Result<(), FleetRoleResolveError> {
42 let role = normalize_token(&role.into()).ok_or(FleetRoleResolveError::EmptyRole)?;
43 let profile =
44 normalize_token(&profile.into()).ok_or(FleetRoleResolveError::EmptyProfile)?;
45 self.roles.insert(role, profile);
46 Ok(())
47 }
48
49 pub fn from_pairs<I, R, P>(pairs: I) -> Result<Self, FleetRoleResolveError>
50 where
51 I: IntoIterator<Item = (R, P)>,
52 R: Into<String>,
53 P: Into<String>,
54 {
55 let mut map = Self::new();
56 for (role, profile) in pairs {
57 map.insert(role, profile)?;
58 }
59 Ok(map)
60 }
61
62 pub fn get(&self, role: &str) -> Option<&str> {
64 let key = normalize_token(role)?;
65 self.roles.get(&key).map(String::as_str)
66 }
67
68 pub fn contains_role(&self, role: &str) -> bool {
69 self.get(role).is_some()
70 }
71
72 pub fn is_empty(&self) -> bool {
73 self.roles.is_empty()
74 }
75
76 pub fn len(&self) -> usize {
77 self.roles.len()
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct ResolvedWorkflowAgent {
84 pub resolved_role: Option<String>,
86 pub resolved_profile: String,
88 pub route_source: &'static str,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Error)]
94pub enum FleetRoleResolveError {
95 #[error("fleet role name must be a non-empty token")]
96 EmptyRole,
97 #[error("fleet profile id must be a non-empty token")]
98 EmptyProfile,
99 #[error("unknown fleet role `{role}`: not present in fleet roster (known roles: {known})")]
100 UnknownRole { role: String, known: String },
101 #[error(
102 "workflow step requires a fleet role or explicit profile; provider/model alone are not identity"
103 )]
104 MissingRoleOrProfile,
105 #[error("role `{role}` must be a non-empty token without whitespace, quotes, or `=`")]
106 InvalidRoleToken { role: String },
107}
108
109pub fn normalize_token(raw: &str) -> Option<String> {
112 let trimmed = raw.trim();
113 if trimmed.is_empty() {
114 return None;
115 }
116 if trimmed
117 .chars()
118 .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '`' | '='))
119 {
120 return None;
121 }
122 Some(trimmed.to_ascii_lowercase())
123}
124
125pub fn validate_role_token(role: &str) -> Result<String, FleetRoleResolveError> {
127 normalize_token(role).ok_or_else(|| FleetRoleResolveError::InvalidRoleToken {
128 role: role.to_string(),
129 })
130}
131
132pub fn resolve_workflow_agent(
140 role: Option<&str>,
141 profile: Option<&str>,
142 fleet: &FleetRoleMap,
143 require_known_role: bool,
144) -> Result<ResolvedWorkflowAgent, FleetRoleResolveError> {
145 let role_norm = match role {
146 Some(raw) => Some(validate_role_token(raw)?),
147 None => None,
148 };
149 let profile_norm =
150 match profile {
151 Some(raw) => Some(validate_role_token(raw).map_err(|_| {
152 FleetRoleResolveError::InvalidRoleToken {
153 role: raw.to_string(),
154 }
155 })?),
156 None => None,
157 };
158
159 if let Some(resolved_profile) = profile_norm {
161 return Ok(ResolvedWorkflowAgent {
162 resolved_role: role_norm,
163 resolved_profile,
164 route_source: "explicit_profile",
165 });
166 }
167
168 let Some(role_name) = role_norm else {
169 return Err(FleetRoleResolveError::MissingRoleOrProfile);
170 };
171
172 if let Some(mapped) = fleet.get(&role_name) {
173 return Ok(ResolvedWorkflowAgent {
174 resolved_role: Some(role_name),
175 resolved_profile: mapped.to_string(),
176 route_source: "fleet_role",
177 });
178 }
179
180 if require_known_role {
181 let known = if fleet.is_empty() {
182 "(none)".to_string()
183 } else {
184 fleet.roles.keys().cloned().collect::<Vec<_>>().join(", ")
185 };
186 return Err(FleetRoleResolveError::UnknownRole {
187 role: role_name,
188 known,
189 });
190 }
191
192 Ok(ResolvedWorkflowAgent {
193 resolved_role: Some(role_name.clone()),
194 resolved_profile: role_name,
195 route_source: "role_as_profile",
196 })
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn stopship_fleet() -> FleetRoleMap {
204 FleetRoleMap::from_pairs([
205 ("scout", "scout"),
206 ("implementer", "builder"),
207 ("reviewer", "reviewer"),
208 ("verifier", "verifier"),
209 ("release_lead", "manager"),
210 ])
211 .expect("valid fleet pairs")
212 }
213
214 #[test]
215 fn known_role_resolves_to_configured_profile() {
216 let fleet = stopship_fleet();
217 let resolved =
218 resolve_workflow_agent(Some("implementer"), None, &fleet, true).expect("resolve");
219 assert_eq!(resolved.resolved_role.as_deref(), Some("implementer"));
220 assert_eq!(resolved.resolved_profile, "builder");
221 assert_eq!(resolved.route_source, "fleet_role");
222 }
223
224 #[test]
225 fn unknown_role_fails_clearly() {
226 let fleet = stopship_fleet();
227 let err = resolve_workflow_agent(Some("wizard"), None, &fleet, true)
228 .expect_err("unknown role must fail");
229 match err {
230 FleetRoleResolveError::UnknownRole { role, known } => {
231 assert_eq!(role, "wizard");
232 assert!(known.contains("scout"), "known={known}");
233 assert!(known.contains("implementer"), "known={known}");
234 }
235 other => panic!("expected UnknownRole, got {other:?}"),
236 }
237 }
238
239 #[test]
240 fn explicit_profile_wins_over_role_map() {
241 let fleet = stopship_fleet();
242 let resolved = resolve_workflow_agent(Some("scout"), Some("custom-scout"), &fleet, true)
243 .expect("resolve");
244 assert_eq!(resolved.resolved_role.as_deref(), Some("scout"));
245 assert_eq!(resolved.resolved_profile, "custom-scout");
246 assert_eq!(resolved.route_source, "explicit_profile");
247 }
248
249 #[test]
250 fn missing_role_and_profile_fails() {
251 let fleet = stopship_fleet();
252 let err = resolve_workflow_agent(None, None, &fleet, true).expect_err("identity required");
253 assert!(matches!(err, FleetRoleResolveError::MissingRoleOrProfile));
254 }
255
256 #[test]
257 fn role_token_rejects_whitespace_and_equals() {
258 for bad in ["", "has space", "role=x", "quote\"y"] {
259 assert!(
260 validate_role_token(bad).is_err(),
261 "token {bad:?} should be rejected"
262 );
263 }
264 assert_eq!(validate_role_token(" Scout ").unwrap(), "scout");
265 }
266
267 #[test]
268 fn require_known_role_false_falls_back_to_role_as_profile() {
269 let fleet = FleetRoleMap::new();
270 let resolved = resolve_workflow_agent(Some("scout"), None, &fleet, false).expect("resolve");
271 assert_eq!(resolved.resolved_profile, "scout");
272 assert_eq!(resolved.route_source, "role_as_profile");
273 }
274}