1use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use thiserror::Error;
24
25use crate::fleet_exact::{
26 EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError,
27 LEGACY_FLEET_SCHEMA_KIND, declared_schema_kind,
28};
29use crate::fleet_snapshot::QualifiedFleetId;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct FleetSearchRoot {
38 pub origin: String,
40 pub root: PathBuf,
42}
43
44impl FleetSearchRoot {
45 pub fn new(origin: impl Into<String>, root: impl Into<PathBuf>) -> Self {
46 Self {
47 origin: origin.into(),
48 root: root.into(),
49 }
50 }
51}
52
53fn split_qualified_fleet_name(name: &str) -> (Option<&str>, &str) {
55 let trimmed = name.trim();
56 match trimmed.split_once('/') {
57 Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
58 (Some(origin.trim()), bare.trim())
59 }
60 _ => (None, trimmed),
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct NamedFleet {
67 pub name: String,
68 #[serde(default)]
69 pub description: Option<String>,
70 pub roles: BTreeMap<String, String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Error)]
75pub enum NamedFleetError {
76 #[error("fleet file not found: {0}")]
77 NotFound(String),
78 #[error("failed to read fleet file {path}: {message}")]
79 Io { path: String, message: String },
80 #[error("failed to parse fleet file {path}: {message}")]
81 Parse { path: String, message: String },
82 #[error("fleet `{fleet}` is missing required role `{role}`")]
83 MissingRole { fleet: String, role: String },
84 #[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
85 NameMismatch { declared: String, expected: String },
86 #[error(
87 "fleet `{name}` is defined in more than one place ({}); an exact fleet must not be \
88 resolved by shadowing. Name one explicitly as `origin/{name}`.",
89 origins.join(", ")
90 )]
91 AmbiguousFleet { name: String, origins: Vec<String> },
92 #[error("exact fleet `{fleet}`: {source}")]
93 Exact {
94 fleet: String,
95 #[source]
96 source: ExactFleetError,
97 },
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case", tag = "kind")]
103pub enum FleetSchema {
104 Legacy(NamedFleet),
106 Exact(ExactFleet),
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct FleetDocument {
113 schema: FleetSchema,
114 source: Option<PathBuf>,
115 source_hash: String,
116}
117
118impl FleetDocument {
119 pub fn parse(text: &str) -> Result<Self, NamedFleetError> {
122 let schema = match declared_schema_kind(text).as_deref() {
123 Some(EXACT_FLEET_SCHEMA_KIND) => {
124 let exact = ExactFleet::parse(text).map_err(|source| NamedFleetError::Exact {
125 fleet: "<memory>".to_string(),
126 source,
127 })?;
128 FleetSchema::Exact(exact)
129 }
130 Some(other) => {
131 return Err(NamedFleetError::Parse {
132 path: "<memory>".into(),
133 message: format!("unknown fleet schema `{other}`; expected `exact`"),
134 });
135 }
136 None => FleetSchema::Legacy(parse_named_fleet(text)?),
137 };
138 Ok(Self {
139 schema,
140 source: None,
141 source_hash: content_hash(text),
142 })
143 }
144
145 pub fn load(path: &Path, expect_name: Option<&str>) -> Result<Self, NamedFleetError> {
146 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
147 path: path.display().to_string(),
148 message: e.to_string(),
149 })?;
150 let mut document = Self::parse(&text).map_err(|e| match e {
151 NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
152 path: path.display().to_string(),
153 message,
154 },
155 NamedFleetError::Exact { source, .. } => NamedFleetError::Exact {
156 fleet: path.display().to_string(),
157 source,
158 },
159 other => other,
160 })?;
161 if let Some(expected) = expect_name
162 && document.name() != expected
163 {
164 return Err(NamedFleetError::NameMismatch {
165 declared: document.name().to_string(),
166 expected: expected.to_string(),
167 });
168 }
169 document.source = Some(path.to_path_buf());
170 Ok(document)
171 }
172
173 pub fn load_by_name(
191 name: &str,
192 search_roots: &[FleetSearchRoot],
193 ) -> Result<(Self, QualifiedFleetId), NamedFleetError> {
194 let (requested_origin, bare_name) = split_qualified_fleet_name(name);
195 let file_name = format!("{bare_name}.toml");
196
197 let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
198 for root in search_roots {
199 if let Some(origin) = requested_origin
200 && !root.origin.eq_ignore_ascii_case(origin)
201 {
202 continue;
203 }
204 let path = root.root.join("fleets").join(&file_name);
205 if path.is_file() {
206 candidates.push((root, path));
207 }
208 }
209
210 let Some((first_root, first_path)) = candidates.first() else {
211 return Err(NamedFleetError::NotFound(name.to_string()));
212 };
213
214 if candidates.len() > 1 {
215 let mut any_exact = false;
223 for (_, path) in &candidates {
224 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
225 path: path.display().to_string(),
226 message: e.to_string(),
227 })?;
228 if declared_schema_kind(&text).is_some() {
229 any_exact = true;
230 break;
231 }
232 }
233 if any_exact {
234 return Err(NamedFleetError::AmbiguousFleet {
235 name: bare_name.to_string(),
236 origins: candidates
237 .iter()
238 .map(|(root, path)| {
239 format!("{}/{bare_name} ({})", root.origin, path.display())
240 })
241 .collect(),
242 });
243 }
244 }
247
248 let document = Self::load(first_path, Some(bare_name))?;
249 Ok((
250 document,
251 QualifiedFleetId {
252 name: bare_name.to_string(),
253 origin: first_root.origin.clone(),
254 },
255 ))
256 }
257
258 #[must_use]
259 pub fn name(&self) -> &str {
260 match &self.schema {
261 FleetSchema::Legacy(fleet) => &fleet.name,
262 FleetSchema::Exact(fleet) => &fleet.name,
263 }
264 }
265
266 #[must_use]
267 pub fn description(&self) -> Option<&str> {
268 match &self.schema {
269 FleetSchema::Legacy(fleet) => fleet.description.as_deref(),
270 FleetSchema::Exact(fleet) => fleet.description.as_deref(),
271 }
272 }
273
274 #[must_use]
275 pub fn schema(&self) -> &FleetSchema {
276 &self.schema
277 }
278
279 #[must_use]
281 pub const fn is_legacy(&self) -> bool {
282 matches!(self.schema, FleetSchema::Legacy(_))
283 }
284
285 #[must_use]
286 pub fn legacy(&self) -> Option<&NamedFleet> {
287 match &self.schema {
288 FleetSchema::Legacy(fleet) => Some(fleet),
289 FleetSchema::Exact(_) => None,
290 }
291 }
292
293 #[must_use]
294 pub fn exact(&self) -> Option<&ExactFleet> {
295 match &self.schema {
296 FleetSchema::Exact(fleet) => Some(fleet),
297 FleetSchema::Legacy(_) => None,
298 }
299 }
300
301 #[must_use]
302 pub fn schema_kind(&self) -> &'static str {
303 match self.schema {
304 FleetSchema::Legacy(_) => LEGACY_FLEET_SCHEMA_KIND,
305 FleetSchema::Exact(_) => EXACT_FLEET_SCHEMA_KIND,
306 }
307 }
308
309 #[must_use]
310 pub fn schema_revision(&self) -> u32 {
311 match &self.schema {
312 FleetSchema::Legacy(_) => 0,
315 FleetSchema::Exact(fleet) => fleet.schema_revision,
316 }
317 }
318
319 #[must_use]
321 pub fn source_hash(&self) -> &str {
322 &self.source_hash
323 }
324
325 #[must_use]
326 pub fn source_path(&self) -> Option<&Path> {
327 self.source.as_deref()
328 }
329
330 #[cfg(test)]
336 #[must_use]
337 pub(crate) fn from_exact_for_tests(exact: ExactFleet) -> Self {
338 Self {
339 schema: FleetSchema::Exact(exact),
340 source: None,
341 source_hash: content_hash("<constructed>"),
342 }
343 }
344}
345
346#[must_use]
348pub const fn exact_schema_revision() -> u32 {
349 EXACT_FLEET_SCHEMA_REVISION
350}
351
352pub(crate) fn content_hash(text: &str) -> String {
353 sha256_label(text.as_bytes())
354}
355
356pub(crate) fn sha256_label(bytes: &[u8]) -> String {
359 use std::fmt::Write as _;
360
361 let digest = Sha256::digest(bytes);
362 let mut out = String::with_capacity(7 + digest.len() * 2);
363 out.push_str("sha256:");
364 for byte in digest.iter() {
365 let _ = write!(&mut out, "{byte:02x}");
366 }
367 out
368}
369
370pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[
372 "scout",
373 "implementer",
374 "reviewer",
375 "verifier",
376 "release_lead",
377];
378
379pub fn parse_named_fleet(toml_text: &str) -> Result<NamedFleet, NamedFleetError> {
381 let trimmed = toml_text.trim();
386 if trimmed.starts_with('{') {
387 return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse {
388 path: "<memory>".into(),
389 message: e.to_string(),
390 });
391 }
392 parse_fleet_toml_minimal(trimmed)
393}
394
395fn strip_toml_comment(line: &str) -> &str {
398 let mut quote = None;
399 let mut escaped = false;
400
401 for (index, character) in line.char_indices() {
402 match quote {
403 Some('"') => {
404 if escaped {
405 escaped = false;
406 } else {
407 match character {
408 '\\' => escaped = true,
409 '"' => quote = None,
410 _ => {}
411 }
412 }
413 }
414 Some('\'') => {
415 if character == '\'' {
416 quote = None;
417 }
418 }
419 Some(_) => unreachable!("only TOML string delimiters are tracked"),
420 None => match character {
421 '"' | '\'' => quote = Some(character),
422 '#' => return &line[..index],
423 _ => {}
424 },
425 }
426 }
427
428 line
429}
430
431fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
432 let mut name = None;
433 let mut description = None;
434 let mut roles = BTreeMap::new();
435 let mut section = "";
436 for raw in text.lines() {
437 let line = strip_toml_comment(raw).trim();
438 if line.is_empty() {
439 continue;
440 }
441 if line.starts_with('[') && line.ends_with(']') {
442 section = &line[1..line.len() - 1];
443 continue;
444 }
445 let Some((key, value)) = line.split_once('=') else {
446 continue;
447 };
448 let key = key.trim();
449 let value = value.trim().trim_matches('"').to_string();
450 match section {
451 "" => match key {
452 "name" => name = Some(value),
453 "description" => description = Some(value),
454 _ => {}
455 },
456 "roles" => {
457 roles.insert(key.to_string(), value);
458 }
459 _ => {}
460 }
461 }
462 let name = name.ok_or_else(|| NamedFleetError::Parse {
463 path: "<memory>".into(),
464 message: "missing name".into(),
465 })?;
466 Ok(NamedFleet {
467 name,
468 description,
469 roles,
470 })
471}
472
473pub fn load_named_fleet(
475 name: &str,
476 search_roots: &[PathBuf],
477) -> Result<NamedFleet, NamedFleetError> {
478 let file_name = format!("{name}.toml");
479 for root in search_roots {
480 let path = root.join("fleets").join(&file_name);
481 if path.is_file() {
482 return load_named_fleet_file(&path, Some(name));
483 }
484 }
485 Err(NamedFleetError::NotFound(name.to_string()))
486}
487
488pub fn load_named_fleet_file(
489 path: &Path,
490 expect_name: Option<&str>,
491) -> Result<NamedFleet, NamedFleetError> {
492 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
493 path: path.display().to_string(),
494 message: e.to_string(),
495 })?;
496 let fleet = parse_named_fleet(&text).map_err(|e| match e {
497 NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
498 path: path.display().to_string(),
499 message,
500 },
501 other => other,
502 })?;
503 if let Some(expected) = expect_name
504 && fleet.name != expected
505 {
506 return Err(NamedFleetError::NameMismatch {
507 declared: fleet.name,
508 expected: expected.to_string(),
509 });
510 }
511 Ok(fleet)
512}
513
514impl NamedFleet {
515 pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
517 let key = role.trim().to_ascii_lowercase();
518 self.roles
519 .get(&key)
520 .or_else(|| {
521 self.roles
522 .iter()
523 .find(|(k, _)| k.eq_ignore_ascii_case(role))
524 .map(|(_, v)| v)
525 })
526 .map(String::as_str)
527 .ok_or_else(|| NamedFleetError::MissingRole {
528 fleet: self.name.clone(),
529 role: role.to_string(),
530 })
531 }
532
533 pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
535 for role in STOPSHIP_REQUIRED_ROLES {
536 self.resolve(role)?;
537 }
538 Ok(())
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 const STOPSHIP_TOML: &str = r#"
547name = "stopship"
548description = "Stopship dogfood fleet"
549
550[roles]
551scout = "scout"
552implementer = "builder"
553reviewer = "reviewer"
554verifier = "verifier"
555release_lead = "manager"
556"#;
557
558 #[test]
559 fn stopship_fleet_resolves_all_five_roles() {
560 let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
561 assert_eq!(fleet.name, "stopship");
562 fleet.validate_stopship_roles().expect("all roles");
563 assert_eq!(fleet.resolve("scout").unwrap(), "scout");
564 assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
565 assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
566 assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
567 assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
568 }
569
570 #[test]
571 fn unknown_role_fails_clearly() {
572 let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
573 let err = fleet.resolve("wizard").unwrap_err();
574 assert!(matches!(err, NamedFleetError::MissingRole { .. }));
575 }
576
577 #[test]
578 fn quoted_hashes_are_not_treated_as_comments() {
579 let fleet = parse_named_fleet(
580 r#"
581name = "issue-references"
582description = "Tracks #4178 dogfood" # real comment
583
584[roles]
585scout = "scout#stable"
586"#,
587 )
588 .expect("parse");
589
590 assert_eq!(fleet.description.as_deref(), Some("Tracks #4178 dogfood"));
591 assert_eq!(fleet.resolve("scout").unwrap(), "scout#stable");
592 }
593
594 #[test]
595 fn comment_stripping_tracks_toml_quotes_and_escapes() {
596 assert_eq!(
597 strip_toml_comment(r##"description = "say \"#still-value\"" # comment"##).trim_end(),
598 r##"description = "say \"#still-value\"""##
599 );
600 assert_eq!(
601 strip_toml_comment("description = 'tracks #4178' # comment").trim_end(),
602 "description = 'tracks #4178'"
603 );
604 assert_eq!(
605 strip_toml_comment(r#"name = "stopship" # comment"#).trim_end(),
606 r#"name = "stopship""#
607 );
608 }
609
610 #[test]
611 fn legacy_fleet_files_still_deserialize_and_resolve_through_the_document_api() {
612 let document = FleetDocument::parse(STOPSHIP_TOML).expect("legacy parse");
613
614 assert!(document.is_legacy());
616 assert_eq!(document.schema_kind(), "legacy");
617 assert_eq!(document.schema_revision(), 0);
618 assert!(document.exact().is_none());
619
620 let legacy = document.legacy().expect("legacy body");
621 legacy.validate_stopship_roles().expect("all roles");
622 assert_eq!(legacy.resolve("implementer").unwrap(), "builder");
623 assert_eq!(document.name(), "stopship");
624 assert!(document.source_hash().starts_with("sha256:"));
625 }
626
627 #[test]
628 fn exact_fleet_files_are_selected_by_an_explicit_schema_key() {
629 let document = FleetDocument::parse(
630 r#"
631name = "glm-pair"
632schema = "exact"
633
634[[members]]
635id = "implementer"
636provider = "zai"
637model = "glm-5"
638reasoning = "auto"
639
640[[members]]
641id = "router"
642kind = "router"
643provider = "zai"
644model = "glm-5-turbo"
645"#,
646 )
647 .expect("exact parse");
648
649 assert!(!document.is_legacy());
650 assert_eq!(document.schema_kind(), "exact");
651 assert_eq!(document.schema_revision(), exact_schema_revision());
652 assert!(document.legacy().is_none());
653 let exact = document.exact().expect("exact body");
654 assert!(exact.has_auto_member());
655 assert!(exact.legacy_inline_router().is_some());
658 assert!(exact.router_ref().is_some());
659 }
660
661 #[test]
662 fn an_unknown_schema_key_fails_instead_of_falling_back_to_legacy() {
663 let err = FleetDocument::parse("name = \"f\"\nschema = \"experimental\"\n")
664 .expect_err("unknown schema must not silently parse as legacy");
665 assert!(matches!(err, NamedFleetError::Parse { .. }), "{err:?}");
666 }
667
668 #[test]
669 fn document_hash_follows_the_file_bytes() {
670 let a = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
671 let b = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
672 let c = FleetDocument::parse(&STOPSHIP_TOML.replace("builder", "implementer_profile"))
673 .expect("parse");
674
675 assert_eq!(a.source_hash(), b.source_hash());
676 assert_ne!(a.source_hash(), c.source_hash());
677 }
678
679 #[test]
680 fn loads_workspace_fleet_file() {
681 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
683 .join("..")
684 .join("..");
685 let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
686 fleet.validate_stopship_roles().unwrap();
687 }
688}