返回 CodeWhale
named_fleet.rs
根目录 / crates / workflow / src / named_fleet.rs
1 //! Named fleet roster files for dogfood lanes (#4178).
2 //!
3 //! Format: TOML at `fleets/<name>.toml` (workspace) or
4 //! `$CODEWHALE_HOME/fleets/<name>.toml`.
5 //!
6 //! Two forms share this one store — there is no parallel fleet directory:
7 //!
8 //! - **Legacy**: `[roles]` maps role → AgentProfile id. Fleet resolves roles →
9 //! profile ids only; Runtime owns tmux/worktrees. Legacy files declare no
10 //! `schema` key, which is what makes the form explicitly detectable rather
11 //! than guessed from a missing table.
12 //! - **Exact**: `schema = "exact"` with fully resolved `[[members]]`. See
13 //! [`crate::fleet_exact`].
14 //!
15 //! [`FleetDocument`] is the form-agnostic entry point: it reports which form a
16 //! file is in and carries the content hash a Workflow snapshot records.
17
18 use std::collections::BTreeMap;
19 use std::path::{Path, PathBuf};
20
21 use serde::{Deserialize, Serialize};
22 use sha2::{Digest, Sha256};
23 use thiserror::Error;
24
25 use crate::fleet_exact::{
26 EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError,
27 LEGACY_FLEET_SCHEMA_KIND, declared_schema_kind,
28 };
29 use crate::fleet_snapshot::QualifiedFleetId;
30
31 /// One labelled place fleet files are looked up.
32 ///
33 /// The label is what makes a Fleet identity *qualified*: `workspace/glm-pair`
34 /// and `codewhale_home/glm-pair` are different Fleets, and the loader refuses
35 /// to guess between them for exact definitions.
36 #[derive(Debug, Clone, PartialEq, Eq)]
37 pub struct FleetSearchRoot {
38 /// Non-secret origin label, e.g. `workspace` or `codewhale_home`.
39 pub origin: String,
40 /// Directory that contains a `fleets/` subdirectory.
41 pub root: PathBuf,
42 }
43
44 impl 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
53 /// Split `origin/name` into its parts. A bare name yields `(None, name)`.
54 ///
55 /// The bare part becomes a `<name>.toml` file name, so it is validated here
56 /// with [`validate_fleet_file_stem`] before any caller touches the disk.
57 pub fn split_qualified_fleet_name(name: &str) -> Result<(Option<&str>, &str), NamedFleetError> {
58 let trimmed = name.trim();
59 let (origin, bare) = match trimmed.split_once('/') {
60 Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
61 (Some(origin.trim()), bare.trim())
62 }
63 _ => (None, trimmed),
64 };
65 validate_fleet_file_stem(bare)?;
66 Ok((origin, bare))
67 }
68
69 /// A fleet or router name is one file name inside a `fleets/`-style
70 /// directory: non-empty, a single normal path component, and free of path
71 /// separators, drive prefixes and NUL. Anything else is refused before a
72 /// path is built from it.
73 pub fn validate_fleet_file_stem(stem: &str) -> Result<(), NamedFleetError> {
74 let single_component = matches!(
75 Path::new(stem).components().collect::<Vec<_>>().as_slice(),
76 [std::path::Component::Normal(_)]
77 );
78 if stem.is_empty()
79 || stem == "."
80 || stem == ".."
81 || stem.contains(['/', '\\', ':', '\0'])
82 || !single_component
83 {
84 return Err(NamedFleetError::InvalidName);
85 }
86 Ok(())
87 }
88
89 /// Parsed named fleet file.
90 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91 pub struct NamedFleet {
92 pub name: String,
93 #[serde(default)]
94 pub description: Option<String>,
95 /// role name → AgentProfile id
96 pub roles: BTreeMap<String, String>,
97 }
98
99 #[derive(Debug, Clone, PartialEq, Eq, Error)]
100 pub enum NamedFleetError {
101 #[error("fleet file not found: {0}")]
102 NotFound(String),
103 /// Carries no text from the rejected name, so the refusal cannot echo a
104 /// path back.
105 #[error(
106 "invalid fleet name: use a plain name (optionally `origin/name`) without path \
107 separators or `..`"
108 )]
109 InvalidName,
110 #[error("failed to read fleet file {path}: {message}")]
111 Io { path: String, message: String },
112 #[error("failed to parse fleet file {path}: {message}")]
113 Parse { path: String, message: String },
114 #[error("fleet `{fleet}` is missing required role `{role}`")]
115 MissingRole { fleet: String, role: String },
116 #[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
117 NameMismatch { declared: String, expected: String },
118 #[error(
119 "fleet `{name}` is defined in more than one place ({}); an exact fleet must not be \
120 resolved by shadowing. Name one explicitly as `origin/{name}`.",
121 origins.join(", ")
122 )]
123 AmbiguousFleet { name: String, origins: Vec<String> },
124 #[error("exact fleet `{fleet}`: {source}")]
125 Exact {
126 fleet: String,
127 #[source]
128 source: ExactFleetError,
129 },
130 }
131
132 /// Which form a `fleets/<name>.toml` file is in.
133 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134 #[serde(rename_all = "snake_case", tag = "kind")]
135 pub enum FleetSchema {
136 /// Pre-exact role → AgentProfile id map.
137 Legacy(NamedFleet),
138 /// Fully resolved exact members.
139 Exact(ExactFleet),
140 }
141
142 /// A parsed fleet file plus the provenance a Workflow snapshot needs.
143 #[derive(Debug, Clone, PartialEq, Eq)]
144 pub struct FleetDocument {
145 schema: FleetSchema,
146 source: Option<PathBuf>,
147 source_hash: String,
148 }
149
150 impl FleetDocument {
151 /// Parse either form. The exact form is selected by an explicit
152 /// `schema = "exact"`; everything else is the legacy form.
153 pub fn parse(text: &str) -> Result<Self, NamedFleetError> {
154 let schema = match declared_schema_kind(text).as_deref() {
155 Some(EXACT_FLEET_SCHEMA_KIND) => {
156 let exact = ExactFleet::parse(text).map_err(|source| NamedFleetError::Exact {
157 fleet: "<memory>".to_string(),
158 source,
159 })?;
160 FleetSchema::Exact(exact)
161 }
162 Some(other) => {
163 return Err(NamedFleetError::Parse {
164 path: "<memory>".into(),
165 message: format!(
166 "unknown fleet schema `{other}`; expected `exact` or a saved Fleet \
167 (`schema = \"fleet\"`, loaded from `.codewhale/fleets/` or \
168 `$CODEWHALE_HOME/fleets/`)"
169 ),
170 });
171 }
172 None => FleetSchema::Legacy(parse_named_fleet(text)?),
173 };
174 Ok(Self {
175 schema,
176 source: None,
177 source_hash: content_hash(text),
178 })
179 }
180
181 pub fn load(path: &Path, expect_name: Option<&str>) -> Result<Self, NamedFleetError> {
182 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
183 path: path.display().to_string(),
184 message: e.to_string(),
185 })?;
186 let mut document = Self::parse(&text).map_err(|e| match e {
187 NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
188 path: path.display().to_string(),
189 message,
190 },
191 NamedFleetError::Exact { source, .. } => NamedFleetError::Exact {
192 fleet: path.display().to_string(),
193 source,
194 },
195 other => other,
196 })?;
197 if let Some(expected) = expect_name
198 && document.name() != expected
199 {
200 return Err(NamedFleetError::NameMismatch {
201 declared: document.name().to_string(),
202 expected: expected.to_string(),
203 });
204 }
205 document.source = Some(path.to_path_buf());
206 Ok(document)
207 }
208
209 /// Load a fleet document by name from labelled search roots.
210 ///
211 /// A bare `name` that exists under more than one origin is **ambiguous**
212 /// once any candidate is an exact fleet: a personal `~/.codewhale` Fleet
213 /// silently shadowing (or being shadowed by) a project Fleet would change
214 /// which exact provider/model actually runs, so the caller is asked for a
215 /// qualified `origin/name` instead. Purely legacy collisions keep the
216 /// historic first-hit-wins behavior, because a role→profile map resolves
217 /// through the same profile store either way.
218 ///
219 /// Ambiguity is decided from a `schema`-key probe, not from a full parse of
220 /// every candidate: a malformed file in a *shadowed* origin must not break a
221 /// legacy load that has always worked. A file whose TOML does not even
222 /// parse therefore counts as legacy for this decision, and the first hit
223 /// still wins — the same outcome the pre-exact loader gave.
224 ///
225 /// Accepts `origin/name` to name one origin explicitly.
226 pub fn load_by_name(
227 name: &str,
228 search_roots: &[FleetSearchRoot],
229 ) -> Result<(Self, QualifiedFleetId), NamedFleetError> {
230 let (requested_origin, bare_name) = split_qualified_fleet_name(name)?;
231 let file_name = format!("{bare_name}.toml");
232
233 let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
234 for root in search_roots {
235 if let Some(origin) = requested_origin
236 && !root.origin.eq_ignore_ascii_case(origin)
237 {
238 continue;
239 }
240 let path = root.root.join("fleets").join(&file_name);
241 if path.is_file() {
242 candidates.push((root, path));
243 }
244 }
245
246 let Some((first_root, first_path)) = candidates.first() else {
247 return Err(NamedFleetError::NotFound(name.to_string()));
248 };
249
250 if candidates.len() > 1 {
251 // Decide ambiguity from the `schema` key alone — a cheap probe that
252 // does not parse the rest of the file. Fully parsing every sibling
253 // would mean a malformed *shadowed* file could fail a load that
254 // legacy first-hit-wins has always satisfied, which is a regression
255 // in a path the exact schema was never meant to touch. The
256 // ambiguity that actually matters is "one of these is exact", and
257 // the probe answers exactly that.
258 let mut any_exact = false;
259 for (_, path) in &candidates {
260 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
261 path: path.display().to_string(),
262 message: e.to_string(),
263 })?;
264 if declared_schema_kind(&text).is_some() {
265 any_exact = true;
266 break;
267 }
268 }
269 if any_exact {
270 return Err(NamedFleetError::AmbiguousFleet {
271 name: bare_name.to_string(),
272 origins: candidates
273 .iter()
274 .map(|(root, path)| {
275 format!("{}/{bare_name} ({})", root.origin, path.display())
276 })
277 .collect(),
278 });
279 }
280 // Purely legacy collision: first hit wins, and only the first hit
281 // is parsed.
282 }
283
284 let document = Self::load(first_path, Some(bare_name))?;
285 Ok((
286 document,
287 QualifiedFleetId {
288 name: bare_name.to_string(),
289 origin: first_root.origin.clone(),
290 },
291 ))
292 }
293
294 #[must_use]
295 pub fn name(&self) -> &str {
296 match &self.schema {
297 FleetSchema::Legacy(fleet) => &fleet.name,
298 FleetSchema::Exact(fleet) => &fleet.name,
299 }
300 }
301
302 #[must_use]
303 pub fn description(&self) -> Option<&str> {
304 match &self.schema {
305 FleetSchema::Legacy(fleet) => fleet.description.as_deref(),
306 FleetSchema::Exact(fleet) => fleet.description.as_deref(),
307 }
308 }
309
310 #[must_use]
311 pub fn schema(&self) -> &FleetSchema {
312 &self.schema
313 }
314
315 /// Explicit legacy detection — never inferred from a missing table.
316 #[must_use]
317 pub const fn is_legacy(&self) -> bool {
318 matches!(self.schema, FleetSchema::Legacy(_))
319 }
320
321 #[must_use]
322 pub fn legacy(&self) -> Option<&NamedFleet> {
323 match &self.schema {
324 FleetSchema::Legacy(fleet) => Some(fleet),
325 FleetSchema::Exact(_) => None,
326 }
327 }
328
329 #[must_use]
330 pub fn exact(&self) -> Option<&ExactFleet> {
331 match &self.schema {
332 FleetSchema::Exact(fleet) => Some(fleet),
333 FleetSchema::Legacy(_) => None,
334 }
335 }
336
337 #[must_use]
338 pub fn schema_kind(&self) -> &'static str {
339 match self.schema {
340 FleetSchema::Legacy(_) => LEGACY_FLEET_SCHEMA_KIND,
341 FleetSchema::Exact(_) => EXACT_FLEET_SCHEMA_KIND,
342 }
343 }
344
345 #[must_use]
346 pub fn schema_revision(&self) -> u32 {
347 match &self.schema {
348 // Legacy files carry no revision; report 0 so a snapshot can tell
349 // "pre-versioned" from exact revision 1.
350 FleetSchema::Legacy(_) => 0,
351 FleetSchema::Exact(fleet) => fleet.schema_revision,
352 }
353 }
354
355 /// SHA-256 of the exact file bytes this document was parsed from.
356 #[must_use]
357 pub fn source_hash(&self) -> &str {
358 &self.source_hash
359 }
360
361 #[must_use]
362 pub fn source_path(&self) -> Option<&Path> {
363 self.source.as_deref()
364 }
365
366 /// An exact document frozen from a saved v2 Fleet at Workflow start.
367 ///
368 /// `frozen_text` is the exact-schema rendering of the saved Fleet with every
369 /// route and reasoning request resolved; it goes through the same exact
370 /// parser as a hand-written file, and [`Self::source_hash`] covers those
371 /// frozen bytes — what actually runs — while [`Self::source_path`] names the
372 /// saved Fleet file it came from.
373 pub fn from_frozen_saved_fleet(
374 frozen_text: &str,
375 source: &Path,
376 ) -> Result<Self, NamedFleetError> {
377 if declared_schema_kind(frozen_text).as_deref() != Some(EXACT_FLEET_SCHEMA_KIND) {
378 return Err(NamedFleetError::Parse {
379 path: source.display().to_string(),
380 message: "a frozen saved Fleet must be in the exact schema".to_string(),
381 });
382 }
383 let mut document = Self::parse(frozen_text).map_err(|error| match error {
384 NamedFleetError::Exact { source: inner, .. } => NamedFleetError::Exact {
385 fleet: source.display().to_string(),
386 source: inner,
387 },
388 other => other,
389 })?;
390 document.source = Some(source.to_path_buf());
391 Ok(document)
392 }
393
394 /// Build a document around an already-constructed exact roster.
395 ///
396 /// Test-only, and deliberately so: it is how a roster that never passed
397 /// through the TOML parser reaches [`crate::FleetSnapshot::capture`], which
398 /// is exactly the bypass capture-time revalidation exists to close.
399 #[cfg(test)]
400 #[must_use]
401 pub(crate) fn from_exact_for_tests(exact: ExactFleet) -> Self {
402 Self {
403 schema: FleetSchema::Exact(exact),
404 source: None,
405 source_hash: content_hash("<constructed>"),
406 }
407 }
408 }
409
410 /// The schema revision an exact document is expected to declare.
411 #[must_use]
412 pub const fn exact_schema_revision() -> u32 {
413 EXACT_FLEET_SCHEMA_REVISION
414 }
415
416 pub(crate) fn content_hash(text: &str) -> String {
417 sha256_label(text.as_bytes())
418 }
419
420 /// `sha256:<hex>` over arbitrary bytes, written out by hand rather than
421 /// relying on a digest `LowerHex` impl.
422 pub(crate) fn sha256_label(bytes: &[u8]) -> String {
423 use std::fmt::Write as _;
424
425 let digest = Sha256::digest(bytes);
426 let mut out = String::with_capacity(7 + digest.len() * 2);
427 out.push_str("sha256:");
428 for byte in digest.iter() {
429 let _ = write!(&mut out, "{byte:02x}");
430 }
431 out
432 }
433
434 /// Required roles for the stopship dogfood fleet (#4178).
435 pub const STOPSHIP_REQUIRED_ROLES: &[&str] =
436 &["explore", "implement", "reviewer", "test", "release_lead"];
437
438 /// Parse a fleet TOML document.
439 pub fn parse_named_fleet(toml_text: &str) -> Result<NamedFleet, NamedFleetError> {
440 // Minimal TOML subset without adding a toml dep to workflow:
441 // accept JSON as well for tests; for TOML use a tiny hand parser for
442 // the documented shape, or serde via json for unit tests.
443 // Prefer JSON if the text looks like JSON; otherwise use line-oriented TOML.
444 let trimmed = toml_text.trim();
445 if trimmed.starts_with('{') {
446 return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse {
447 path: "<memory>".into(),
448 message: e.to_string(),
449 });
450 }
451 parse_fleet_toml_minimal(trimmed)
452 }
453
454 /// Strip comments from the single-line basic and literal strings supported by
455 /// the minimal fleet parser.
456 fn strip_toml_comment(line: &str) -> &str {
457 let mut quote = None;
458 let mut escaped = false;
459
460 for (index, character) in line.char_indices() {
461 match quote {
462 Some('"') => {
463 if escaped {
464 escaped = false;
465 } else {
466 match character {
467 '\\' => escaped = true,
468 '"' => quote = None,
469 _ => {}
470 }
471 }
472 }
473 Some('\'') => {
474 if character == '\'' {
475 quote = None;
476 }
477 }
478 Some(_) => unreachable!("only TOML string delimiters are tracked"),
479 None => match character {
480 '"' | '\'' => quote = Some(character),
481 '#' => return &line[..index],
482 _ => {}
483 },
484 }
485 }
486
487 line
488 }
489
490 fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
491 let mut name = None;
492 let mut description = None;
493 let mut roles = BTreeMap::new();
494 let mut section = "";
495 for raw in text.lines() {
496 let line = strip_toml_comment(raw).trim();
497 if line.is_empty() {
498 continue;
499 }
500 if line.starts_with('[') && line.ends_with(']') {
501 section = &line[1..line.len() - 1];
502 continue;
503 }
504 let Some((key, value)) = line.split_once('=') else {
505 continue;
506 };
507 let key = key.trim();
508 let value = value.trim().trim_matches('"').to_string();
509 match section {
510 "" => match key {
511 "name" => name = Some(value),
512 "description" => description = Some(value),
513 _ => {}
514 },
515 "roles" => {
516 roles.insert(key.to_string(), value);
517 }
518 _ => {}
519 }
520 }
521 let name = name.ok_or_else(|| NamedFleetError::Parse {
522 path: "<memory>".into(),
523 message: "missing name".into(),
524 })?;
525 Ok(NamedFleet {
526 name,
527 description,
528 roles,
529 })
530 }
531
532 /// Load fleet by name from search paths (first hit wins).
533 pub fn load_named_fleet(
534 name: &str,
535 search_roots: &[PathBuf],
536 ) -> Result<NamedFleet, NamedFleetError> {
537 validate_fleet_file_stem(name)?;
538 let file_name = format!("{name}.toml");
539 for root in search_roots {
540 let path = root.join("fleets").join(&file_name);
541 if path.is_file() {
542 return load_named_fleet_file(&path, Some(name));
543 }
544 }
545 Err(NamedFleetError::NotFound(name.to_string()))
546 }
547
548 pub fn load_named_fleet_file(
549 path: &Path,
550 expect_name: Option<&str>,
551 ) -> Result<NamedFleet, NamedFleetError> {
552 let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
553 path: path.display().to_string(),
554 message: e.to_string(),
555 })?;
556 let fleet = parse_named_fleet(&text).map_err(|e| match e {
557 NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
558 path: path.display().to_string(),
559 message,
560 },
561 other => other,
562 })?;
563 if let Some(expected) = expect_name
564 && fleet.name != expected
565 {
566 return Err(NamedFleetError::NameMismatch {
567 declared: fleet.name,
568 expected: expected.to_string(),
569 });
570 }
571 Ok(fleet)
572 }
573
574 impl NamedFleet {
575 /// Resolve a role name to a profile id.
576 pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
577 let key = role.trim().to_ascii_lowercase();
578 self.roles
579 .get(&key)
580 .or_else(|| {
581 self.roles
582 .iter()
583 .find(|(k, _)| k.eq_ignore_ascii_case(role))
584 .map(|(_, v)| v)
585 })
586 .map(String::as_str)
587 .ok_or_else(|| NamedFleetError::MissingRole {
588 fleet: self.name.clone(),
589 role: role.to_string(),
590 })
591 }
592
593 /// Ensure all required stopship roles are present.
594 pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
595 for role in STOPSHIP_REQUIRED_ROLES {
596 self.resolve(role)?;
597 }
598 Ok(())
599 }
600 }
601
602 #[cfg(test)]
603 mod tests {
604 use super::*;
605
606 const STOPSHIP_TOML: &str = r#"
607 name = "stopship"
608 description = "Stopship dogfood fleet"
609
610 [roles]
611 explore = "scout"
612 implement = "builder"
613 reviewer = "reviewer"
614 test = "verifier"
615 release_lead = "manager"
616 "#;
617
618 #[test]
619 fn stopship_fleet_resolves_all_five_roles() {
620 let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
621 assert_eq!(fleet.name, "stopship");
622 fleet.validate_stopship_roles().expect("all roles");
623 assert_eq!(fleet.resolve("explore").unwrap(), "scout");
624 assert_eq!(fleet.resolve("implement").unwrap(), "builder");
625 assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
626 assert_eq!(fleet.resolve("test").unwrap(), "verifier");
627 assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
628 }
629
630 #[test]
631 fn fleet_names_cannot_leave_the_fleets_directory() {
632 let tmp = tempfile::tempdir().expect("tempdir");
633 let ws = tmp.path().join("ws");
634 std::fs::create_dir_all(ws.join("fleets")).expect("fleets dir");
635 let planted = STOPSHIP_TOML.replace("\"stopship\"", "\"outside\"");
636 // Reachable by `workspace/../../outside` and by an absolute name.
637 std::fs::write(tmp.path().join("outside.toml"), &planted).expect("outside");
638 // Reachable by a bare `../outside` from `ws/fleets`.
639 std::fs::write(ws.join("outside.toml"), &planted).expect("sibling");
640 let roots = vec![FleetSearchRoot::new("workspace", &ws)];
641 let absolute = tmp.path().join("outside");
642 let absolute = absolute.to_string_lossy();
643
644 for name in [
645 "workspace/../../outside",
646 "workspace/../outside",
647 absolute.as_ref(),
648 "workspace/sub/outside",
649 "..",
650 "",
651 ] {
652 let err = FleetDocument::load_by_name(name, &roots).expect_err(name);
653 assert!(
654 matches!(err, NamedFleetError::InvalidName),
655 "{name}: expected InvalidName, got {err:?}"
656 );
657 assert!(
658 !err.to_string()
659 .contains(tmp.path().to_string_lossy().as_ref())
660 );
661 }
662 let err = load_named_fleet("../outside", std::slice::from_ref(&ws)).expect_err("legacy");
663 assert!(matches!(err, NamedFleetError::InvalidName), "{err:?}");
664
665 // A plain name still loads.
666 std::fs::write(ws.join("fleets").join("stopship.toml"), STOPSHIP_TOML).expect("fleet");
667 FleetDocument::load_by_name("stopship", &roots).expect("plain name");
668 FleetDocument::load_by_name("workspace/stopship", &roots).expect("qualified name");
669 load_named_fleet("stopship", std::slice::from_ref(&ws)).expect("legacy plain name");
670 }
671
672 #[test]
673 fn unknown_role_fails_clearly() {
674 let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
675 let err = fleet.resolve("wizard").unwrap_err();
676 assert!(matches!(err, NamedFleetError::MissingRole { .. }));
677 }
678
679 #[test]
680 fn quoted_hashes_are_not_treated_as_comments() {
681 let fleet = parse_named_fleet(
682 r#"
683 name = "issue-references"
684 description = "Tracks #4178 dogfood" # real comment
685
686 [roles]
687 scout = "scout#stable"
688 "#,
689 )
690 .expect("parse");
691
692 assert_eq!(fleet.description.as_deref(), Some("Tracks #4178 dogfood"));
693 assert_eq!(fleet.resolve("scout").unwrap(), "scout#stable");
694 }
695
696 #[test]
697 fn comment_stripping_tracks_toml_quotes_and_escapes() {
698 assert_eq!(
699 strip_toml_comment(r##"description = "say \"#still-value\"" # comment"##).trim_end(),
700 r##"description = "say \"#still-value\"""##
701 );
702 assert_eq!(
703 strip_toml_comment("description = 'tracks #4178' # comment").trim_end(),
704 "description = 'tracks #4178'"
705 );
706 assert_eq!(
707 strip_toml_comment(r#"name = "stopship" # comment"#).trim_end(),
708 r#"name = "stopship""#
709 );
710 }
711
712 #[test]
713 fn legacy_fleet_files_still_deserialize_and_resolve_through_the_document_api() {
714 let document = FleetDocument::parse(STOPSHIP_TOML).expect("legacy parse");
715
716 // Legacy is explicitly detectable, not inferred.
717 assert!(document.is_legacy());
718 assert_eq!(document.schema_kind(), "legacy");
719 assert_eq!(document.schema_revision(), 0);
720 assert!(document.exact().is_none());
721
722 let legacy = document.legacy().expect("legacy body");
723 legacy.validate_stopship_roles().expect("all roles");
724 assert_eq!(legacy.resolve("implement").unwrap(), "builder");
725 assert_eq!(document.name(), "stopship");
726 assert!(document.source_hash().starts_with("sha256:"));
727 }
728
729 #[test]
730 fn exact_fleet_files_are_selected_by_an_explicit_schema_key() {
731 let document = FleetDocument::parse(
732 r#"
733 name = "glm-pair"
734 schema = "exact"
735
736 [[members]]
737 id = "implementer"
738 provider = "zai"
739 model = "glm-5"
740 reasoning = "auto"
741
742 [[members]]
743 id = "router"
744 kind = "router"
745 provider = "zai"
746 model = "glm-5-turbo"
747 "#,
748 )
749 .expect("exact parse");
750
751 assert!(!document.is_legacy());
752 assert_eq!(document.schema_kind(), "exact");
753 assert_eq!(document.schema_revision(), exact_schema_revision());
754 assert!(document.legacy().is_none());
755 let exact = document.exact().expect("exact body");
756 assert!(exact.has_auto_member());
757 // The prototype inline form still parses, and is reported as the legacy
758 // inline router rather than as a second runtime concept.
759 assert!(exact.legacy_inline_router().is_some());
760 assert!(exact.router_ref().is_some());
761 }
762
763 #[test]
764 fn an_unknown_schema_key_fails_instead_of_falling_back_to_legacy() {
765 let err = FleetDocument::parse("name = \"f\"\nschema = \"experimental\"\n")
766 .expect_err("unknown schema must not silently parse as legacy");
767 assert!(matches!(err, NamedFleetError::Parse { .. }), "{err:?}");
768 }
769
770 #[test]
771 fn document_hash_follows_the_file_bytes() {
772 let a = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
773 let b = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
774 let c = FleetDocument::parse(&STOPSHIP_TOML.replace("builder", "implementer_profile"))
775 .expect("parse");
776
777 assert_eq!(a.source_hash(), b.source_hash());
778 assert_ne!(a.source_hash(), c.source_hash());
779 }
780
781 #[test]
782 fn loads_workspace_fleet_file() {
783 // Relative to crate CARGO_MANIFEST_DIR → repo root fleets/
784 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
785 .join("..")
786 .join("..");
787 let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
788 fleet.validate_stopship_roles().unwrap();
789 }
790
791 #[test]
792 fn a_frozen_saved_fleet_is_exact_and_names_its_source_file() {
793 let source = Path::new("/saved/.codewhale/fleets/release.toml");
794 let frozen = "schema = \"exact\"\nschema_revision = 1\nname = \"release\"\n\n\
795 [[members]]\nid = \"builder\"\nrole = \"implement\"\n\
796 provider = \"zai\"\nmodel = \"glm-5\"\nreasoning = \"high\"\n";
797 let document = FleetDocument::from_frozen_saved_fleet(frozen, source).expect("frozen");
798 assert!(document.exact().is_some());
799 assert_eq!(document.source_path(), Some(source));
800 assert_eq!(document.source_hash(), content_hash(frozen));
801
802 // Anything that is not the exact schema is refused, never parsed as a
803 // legacy role map.
804 let error = FleetDocument::from_frozen_saved_fleet(
805 "name = \"release\"\n[roles]\nimplement = \"builder\"\n",
806 source,
807 )
808 .expect_err("legacy text is not a frozen snapshot");
809 assert!(error.to_string().contains("exact schema"), "{error}");
810 }
811
812 #[test]
813 fn a_saved_fleet_schema_is_named_in_the_unknown_schema_error() {
814 let error = FleetDocument::parse("schema = \"fleet\"\nname = \"x\"\n").unwrap_err();
815 let message = error.to_string();
816 assert!(
817 message.contains("expected `exact` or a saved Fleet"),
818 "{message}"
819 );
820 assert!(message.contains(".codewhale/fleets/"), "{message}");
821 }
822 }
823
823 lines RUST