返回 CodeWhale
store.rs
根目录 / crates / tui / src / fleet / store.rs
1 //! The saved named Fleet — the single configuration concept for the whole
2 //! Fleet surface. Its v2 compatibility storage keeps `schema = "fleet"`.
3 //!
4 //! A Fleet is one self-contained TOML file. It owns:
5 //!
6 //! - its **operator** route (provider + exact model + reasoning), or the
7 //! explicit absence of one ("inherit the session route");
8 //! - its **roster**: each member's stable id, optional human-facing name, role,
9 //! exact model pin or inherit policy, provider (pins only — never inferred
10 //! from a model string), reasoning level, optional instructions, and
11 //! capability requirements;
12 //! - its **save scope and source**: personal (`$CODEWHALE_HOME/fleets/`) or
13 //! workspace (`.codewhale/fleets/`), with the exact file path surfaced.
14 //!
15 //! There is exactly one store. The legacy per-role profile files
16 //! (`~/.codewhale/agents/*.toml`, `.codewhale/agents/*.toml`,
17 //! `[fleet.profiles]`) and the workflow crate's `exact`/legacy named-fleet
18 //! files are migration/compat input only — read here, never shadowed, never
19 //! the runtime winner alongside a v2 Fleet.
20 //!
21 //! Selection is a scope-explicit file: `fleets/selected` under the personal
22 //! root is the user-global default; the same file under the workspace root is
23 //! an intentional workspace selection. Workspace selection wins; both are
24 //! labeled in the UI. A workspace selection can never hide or rewrite a
25 //! personal Fleet.
26
27 use std::collections::BTreeMap;
28 use std::fs;
29 use std::path::{Path, PathBuf};
30
31 use crate::config::ProviderKind;
32
33 use serde::{Deserialize, Serialize};
34 use thiserror::Error;
35
36 use super::roster::FleetRoster;
37
38 pub const FLEET_SCHEMA_KIND: &str = "fleet";
39 pub const FLEET_SCHEMA_REVISION: u32 = 2;
40 const MAX_MEMBER_DISPLAY_NAME_CHARS: usize = 80;
41 const MAX_FLEET_NAME_CHARS: usize = 120;
42 const MAX_MEMBER_ID_CHARS: usize = 64;
43 const MAX_MEMBER_ROLE_CHARS: usize = 80;
44 const MAX_ROUTE_FIELD_CHARS: usize = 256;
45 const MAX_MEMBER_INSTRUCTIONS_CHARS: usize = 32 * 1024;
46 const MAX_FLEET_DESCRIPTION_CHARS: usize = 32 * 1024;
47
48 /// A value that reaches selectors, receipts, and terminal rendering: printable
49 /// and bounded. `multiline` admits newlines and tabs (instruction overlays)
50 /// but no other control characters.
51 fn validate_text(
52 what: &str,
53 value: &str,
54 max_chars: usize,
55 multiline: bool,
56 ) -> Result<(), FleetStoreError> {
57 let printable = value
58 .chars()
59 .all(|ch| !ch.is_control() || (multiline && matches!(ch, '\n' | '\r' | '\t')));
60 if !printable || value.chars().count() > max_chars {
61 return Err(FleetStoreError::Invalid(format!(
62 "{what} must be printable {} no longer than {max_chars} characters",
63 if multiline {
64 "text"
65 } else {
66 "single-line text"
67 }
68 )));
69 }
70 Ok(())
71 }
72
73 /// The directory name used by both roots (next to `agents/` for legacy
74 /// profiles). Also used by the workflow crate for its own legacy/exact files;
75 /// v2 files in the same directory are simply a newer schema.
76 pub const FLEET_DIR: &str = "fleets";
77 pub const SELECTED_FILE: &str = "selected";
78
79 /// Where a Fleet was saved. This is the pin target: personal = user-global,
80 /// workspace = folder-scoped.
81 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82 #[serde(rename_all = "snake_case")]
83 pub enum FleetScope {
84 Personal,
85 Workspace,
86 }
87
88 impl FleetScope {
89 /// Short label for UI and receipts: "user" / "folder".
90 #[must_use]
91 pub const fn label(self) -> &'static str {
92 match self {
93 Self::Personal => "user",
94 Self::Workspace => "folder",
95 }
96 }
97
98 #[must_use]
99 pub const fn long_label(self) -> &'static str {
100 match self {
101 Self::Personal => "user-global",
102 Self::Workspace => "folder (this workspace)",
103 }
104 }
105
106 #[must_use]
107 pub const fn toggled(self) -> Self {
108 match self {
109 Self::Personal => Self::Workspace,
110 Self::Workspace => Self::Personal,
111 }
112 }
113 }
114
115 /// A Fleet's own operator route. Absent = inherit the live session route.
116 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117 #[serde(deny_unknown_fields)]
118 pub struct FleetOperator {
119 /// Exact provider id (a `[providers.<id>]` key or a built-in id).
120 pub provider: String,
121 /// Exact model id on that provider's route.
122 pub model: String,
123 /// Reasoning level, only when the resolved route genuinely supports it.
124 /// Absent = inherit the session tier.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub reasoning: Option<String>,
127 }
128
129 /// Capability requirements a member must satisfy. The vocabulary is closed so
130 /// an unknown requirement is a specific error, never a silent reinterpretation.
131 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
132 pub enum MemberCapability {
133 /// Image input: the member must run on a route that accepts images.
134 Vision,
135 }
136
137 impl MemberCapability {
138 pub const VOCABULARY: [&'static str; 1] = ["vision"];
139
140 pub fn parse(value: &str) -> Option<Self> {
141 match value.trim().to_ascii_lowercase().as_str() {
142 "vision" | "image" | "image-input" => Some(Self::Vision),
143 _ => None,
144 }
145 }
146
147 #[must_use]
148 pub const fn wire_name(self) -> &'static str {
149 match self {
150 Self::Vision => "vision",
151 }
152 }
153 }
154
155 /// One roster member of a Fleet.
156 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157 #[serde(deny_unknown_fields)]
158 pub struct FleetMember {
159 /// Stable member id — the role identity (e.g. `scout`, `builder`).
160 pub id: String,
161 /// Optional human-facing name used by roster views and member selectors.
162 ///
163 /// `name` is accepted as an authoring alias, while canonical saves use
164 /// `display_name`. Existing revision-2 files omit this field and continue
165 /// to deserialize unchanged.
166 #[serde(default, alias = "name", skip_serializing_if = "Option::is_none")]
167 pub display_name: Option<String>,
168 /// A role-less model choice, not an executable roster member. Omitted
169 /// in legacy files, whose role/id interpretation stays unchanged.
170 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171 pub shortlist: bool,
172 /// Role label; defaults to `id` when absent on a non-shortlist member.
173 #[serde(default, skip_serializing_if = "String::is_empty")]
174 pub role: String,
175 /// Exact model pin. Absent with `provider` absent = inherit the session
176 /// route (the operator route when the Fleet has one).
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub model: Option<String>,
179 /// Exact provider id for `model`. Pins only: a member must never carry
180 /// `provider` without `model` (rejected at parse), and the provider is
181 /// never inferred from the model string.
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub provider: Option<String>,
184 /// Reasoning level for this member, only when the resolved route
185 /// supports it. Absent = inherit.
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub reasoning: Option<String>,
188 /// Optional instruction overlay for the role.
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub instructions: Option<String>,
191 /// Capability requirements, e.g. `["vision"]`. Validated against
192 /// [`MemberCapability::VOCABULARY`] at parse.
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
194 pub requires: Vec<String>,
195 }
196
197 impl FleetMember {
198 /// The role this member fills: `role`, or `id` when the document left
199 /// the role field off. A shortlisted model has no role.
200 #[must_use]
201 pub fn role_label(&self) -> &str {
202 if self.shortlist {
203 return "";
204 }
205 let role = self.role.trim();
206 if role.is_empty() {
207 self.id.trim()
208 } else {
209 role
210 }
211 }
212
213 /// A row that was a model pin promoted to a member: no role, and an id
214 /// that is just the model's slug. Such rows are not members.
215 #[must_use]
216 pub fn is_bare_model_pin(&self) -> bool {
217 !self.shortlist
218 && self.role.trim().is_empty()
219 && self
220 .model
221 .as_deref()
222 .is_some_and(|model| self.id.trim().starts_with(slugify(model).as_str()))
223 }
224 }
225
226 /// Provider kinds have documented aliases; named custom routes have exact
227 /// keys. Treating every provider name as case-insensitive merges distinct
228 /// endpoints before the configured route binder can resolve them.
229 pub(crate) fn provider_ids_match(saved: &str, requested: &str) -> bool {
230 saved.trim() == requested.trim()
231 || ProviderKind::parse(saved)
232 .filter(|provider| *provider != ProviderKind::Custom)
233 .is_some_and(|provider| Some(provider) == ProviderKind::parse(requested))
234 }
235
236 /// The member pins exactly `provider`/`model`.
237 pub(crate) fn member_pins(member: &FleetMember, provider: &str, model: &str) -> bool {
238 member
239 .provider
240 .as_deref()
241 .is_some_and(|p| provider_ids_match(p, provider))
242 && member.model.as_deref().is_some_and(|id| id == model)
243 }
244
245 /// The saved named Fleet document (compatibility `schema = "fleet"`, revision 2).
246 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247 #[serde(deny_unknown_fields)]
248 pub struct FleetFile {
249 pub schema: String,
250 pub schema_revision: u32,
251 /// Editable display name. Unique per scope (the file slug is derived
252 /// from it); the same name may exist in both scopes, distinguished by
253 /// origin, never silently shadowed.
254 pub name: String,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub description: Option<String>,
257 /// The Fleet's own operator route. Absent = inherit the session route.
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub operator: Option<FleetOperator>,
260 #[serde(default)]
261 pub members: Vec<FleetMember>,
262 }
263
264 /// Why a Fleet file could not be used.
265 #[derive(Debug, Clone, PartialEq, Eq, Error)]
266 pub enum FleetStoreError {
267 #[error("invalid fleet: {0}")]
268 Invalid(String),
269 #[error(
270 "fleet `{0}` is defined in both {1} and {2}; name one explicitly as {1}/{0} or {2}/{0}"
271 )]
272 Ambiguous(String, String, String),
273 #[error("fleet file not found: {0}")]
274 NotFound(String),
275 #[error("failed to read {path}: {message}")]
276 Io { path: String, message: String },
277 #[error("failed to parse {path}: {message}")]
278 Parse { path: String, message: String },
279 #[error("a fleet named `{name}` already exists at {path}; rename it or choose another name")]
280 NameTaken { name: String, path: String },
281 }
282
283 impl FleetFile {
284 /// Create a validated v2 Fleet file.
285 pub fn new(name: String, description: Option<String>) -> Result<Self, FleetStoreError> {
286 let fleet = Self {
287 schema: FLEET_SCHEMA_KIND.to_string(),
288 schema_revision: FLEET_SCHEMA_REVISION,
289 name,
290 description,
291 operator: None,
292 members: Vec::new(),
293 };
294 fleet.validate()?;
295 Ok(fleet)
296 }
297
298 /// Validate the document: name, member ids, pin symmetry, capability
299 /// vocabulary. Invalid input is rejected with a specific error — never
300 /// silently reinterpreted.
301 pub fn validate(&self) -> Result<(), FleetStoreError> {
302 if self.schema != FLEET_SCHEMA_KIND {
303 return Err(FleetStoreError::Invalid(format!(
304 "unknown schema `{}`; expected `{FLEET_SCHEMA_KIND}`",
305 self.schema
306 )));
307 }
308 if self.schema_revision != FLEET_SCHEMA_REVISION {
309 return Err(FleetStoreError::Invalid(format!(
310 "unsupported schema revision {}; this build reads revision {FLEET_SCHEMA_REVISION}",
311 self.schema_revision
312 )));
313 }
314 let name = self.name.trim();
315 if name.is_empty() {
316 return Err(FleetStoreError::Invalid(
317 "fleet name must not be empty".to_string(),
318 ));
319 }
320 // The stored name, not its trimmed view: a control character before
321 // or after the name reaches selectors and terminal rendering too.
322 validate_text("fleet name", &self.name, MAX_FLEET_NAME_CHARS, false)?;
323 if let Some(description) = self.description.as_deref() {
324 validate_text(
325 "fleet description",
326 description,
327 MAX_FLEET_DESCRIPTION_CHARS,
328 true,
329 )?;
330 }
331 // The operator route flows into every inheriting member's route, so
332 // it is bounded exactly like a member's pin.
333 if let Some(operator) = &self.operator {
334 for (field, value) in [
335 ("provider", Some(operator.provider.as_str())),
336 ("model", Some(operator.model.as_str())),
337 ("reasoning", operator.reasoning.as_deref()),
338 ] {
339 if let Some(value) = value {
340 validate_text(
341 &format!("operator {field}"),
342 value,
343 MAX_ROUTE_FIELD_CHARS,
344 false,
345 )?;
346 }
347 }
348 }
349 let mut seen: BTreeMap<String, String> = BTreeMap::new();
350 for member in &self.members {
351 let member_id = member.id.trim();
352 if member_id.is_empty() {
353 return Err(FleetStoreError::Invalid(
354 "member id must not be empty".to_string(),
355 ));
356 }
357 // The id is dispatch identity: exact, not merely equal once trimmed.
358 if member_id != member.id {
359 return Err(FleetStoreError::Invalid(format!(
360 "member id {:?} must not carry leading or trailing whitespace",
361 member.id
362 )));
363 }
364 validate_text("member id", member_id, MAX_MEMBER_ID_CHARS, false)?;
365 let fields = [
366 (
367 "role",
368 Some(member.role.as_str()),
369 MAX_MEMBER_ROLE_CHARS,
370 false,
371 ),
372 (
373 "provider",
374 member.provider.as_deref(),
375 MAX_ROUTE_FIELD_CHARS,
376 false,
377 ),
378 (
379 "model",
380 member.model.as_deref(),
381 MAX_ROUTE_FIELD_CHARS,
382 false,
383 ),
384 (
385 "reasoning",
386 member.reasoning.as_deref(),
387 MAX_ROUTE_FIELD_CHARS,
388 false,
389 ),
390 (
391 "instructions",
392 member.instructions.as_deref(),
393 MAX_MEMBER_INSTRUCTIONS_CHARS,
394 true,
395 ),
396 ];
397 for (field, value, max_chars, multiline) in fields {
398 if let Some(value) = value {
399 validate_text(
400 &format!("member `{member_id}` {field}"),
401 value,
402 max_chars,
403 multiline,
404 )?;
405 }
406 }
407 let member_key = member_id.to_ascii_lowercase();
408 if let Some(existing) = seen.insert(member_key, member.id.clone()) {
409 return Err(FleetStoreError::Invalid(format!(
410 "duplicate member id `{}` conflicts case-insensitively with `{existing}`",
411 member.id,
412 )));
413 }
414 if let Some(display_name) = member.display_name.as_deref() {
415 let trimmed = display_name.trim();
416 if trimmed.is_empty() {
417 return Err(FleetStoreError::Invalid(format!(
418 "member `{}` display_name must not be empty",
419 member.id,
420 )));
421 }
422 if trimmed != display_name
423 || display_name.chars().any(char::is_control)
424 || display_name.chars().count() > MAX_MEMBER_DISPLAY_NAME_CHARS
425 {
426 return Err(FleetStoreError::Invalid(format!(
427 "member `{}` display_name must be one trimmed printable line no longer than {MAX_MEMBER_DISPLAY_NAME_CHARS} characters",
428 member.id,
429 )));
430 }
431 }
432 match (&member.provider, &member.model) {
433 (Some(_), None) | (None, Some(_)) => {
434 return Err(FleetStoreError::Invalid(format!(
435 "member `{}` must pin both provider and model, or neither (inherit); a lone {} is rejected",
436 member.id,
437 if member.provider.is_some() {
438 "provider"
439 } else {
440 "model"
441 }
442 )));
443 }
444 _ => {}
445 }
446 if member.shortlist
447 && (!member.role.trim().is_empty()
448 || member
449 .provider
450 .as_deref()
451 .is_none_or(|id| id.trim().is_empty())
452 || member
453 .model
454 .as_deref()
455 .is_none_or(|id| id.trim().is_empty()))
456 {
457 return Err(FleetStoreError::Invalid(format!(
458 "shortlisted member `{}` must have no role and pin both provider and model",
459 member.id,
460 )));
461 }
462 if member.shortlist
463 && (member
464 .reasoning
465 .as_deref()
466 .is_some_and(|value| !value.trim().is_empty())
467 || member
468 .instructions
469 .as_deref()
470 .is_some_and(|value| !value.trim().is_empty())
471 || !member.requires.is_empty())
472 {
473 return Err(FleetStoreError::Invalid(format!(
474 "shortlisted member `{}` cannot set role reasoning, instructions, or capability requirements",
475 member.id,
476 )));
477 }
478 for requirement in &member.requires {
479 if MemberCapability::parse(requirement).is_none() {
480 return Err(FleetStoreError::Invalid(format!(
481 "member `{}` requires unknown capability `{requirement}`; valid values: {}",
482 member.id,
483 MemberCapability::VOCABULARY.join(", ")
484 )));
485 }
486 }
487 }
488 Ok(())
489 }
490
491 /// Render the canonical TOML document.
492 pub fn render_toml(&self) -> Result<String, FleetStoreError> {
493 self.validate()?;
494 let rendered = toml::to_string_pretty(self)
495 .map_err(|e| FleetStoreError::Invalid(format!("failed to serialize fleet: {e}")))?;
496 Ok(rendered)
497 }
498
499 /// Parse a v2 fleet document from TOML text.
500 pub fn parse(text: &str) -> Result<Self, FleetStoreError> {
501 let mut fleet: Self = toml::from_str(text)
502 .map_err(|e| FleetStoreError::Invalid(format!("invalid fleet TOML: {e}")))?;
503 // Compat (0.9.12): every model the user ever selected was enrolled
504 // as a role-less member with a slug id. Roles are the members; drop
505 // those rows on read so the roster reads as roles again. The next
506 // save writes the clean document.
507 fleet.members.retain(|member| !member.is_bare_model_pin());
508 // #6037: a member pinned to the fleet's own operator route resolves
509 // to that route either way; the pin only stops it following when the
510 // operator moves (a vendor retiring the id, an operator switching
511 // models). Read the redundant pin as the inheritance it always meant.
512 // Shortlist rows keep their pin — it is their entire content.
513 if let Some(operator) = &fleet.operator {
514 for member in &mut fleet.members {
515 if !member.shortlist && member_pins(member, &operator.provider, &operator.model) {
516 member.provider = None;
517 member.model = None;
518 }
519 }
520 }
521 fleet.validate()?;
522 Ok(fleet)
523 }
524
525 /// A stable file slug derived from the display name. Safe across the
526 /// filesystems Codewhale supports; collisions are detected at save.
527 #[must_use]
528 pub fn file_slug(&self) -> String {
529 slugify(&self.name)
530 }
531
532 /// Look up an executable member by role id. Shortlisted model ids never
533 /// select a role, even when they happen to match one.
534 #[must_use]
535 pub fn member(&self, id: &str) -> Option<&FleetMember> {
536 let id = id.trim();
537 self.members
538 .iter()
539 .find(|member| !member.shortlist && member.id.trim().eq_ignore_ascii_case(id))
540 }
541
542 /// Whether the roster contains a scout member (the fast exploratory role).
543 #[must_use]
544 pub fn has_scout(&self) -> bool {
545 self.member("scout").is_some()
546 }
547 }
548
549 /// Sanitize a display name into a safe file slug.
550 pub(crate) fn slugify(name: &str) -> String {
551 let mut slug = String::with_capacity(name.len());
552 for ch in name.trim().chars() {
553 if ch.is_ascii_alphanumeric() {
554 slug.push(ch.to_ascii_lowercase());
555 } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !slug.ends_with('-') {
556 slug.push('-');
557 }
558 }
559 while slug.ends_with('-') {
560 slug.pop();
561 }
562 if slug.is_empty() {
563 "fleet".to_string()
564 } else {
565 slug
566 }
567 }
568
569 /// One entry in the Fleet list: name, scope, exact path, and health.
570 #[derive(Debug, Clone, PartialEq, Eq)]
571 pub struct FleetEntry {
572 pub name: String,
573 pub scope: FleetScope,
574 /// Exact path of the saved file.
575 pub path: PathBuf,
576 /// Parse failure, when the file exists but cannot be read as a v2 Fleet.
577 pub parse_error: Option<String>,
578 /// Whether the file is a legacy (pre-v2) named-fleet file (exact or
579 /// roles map) that is read for compatibility but not editable as v2.
580 pub legacy: bool,
581 }
582
583 /// The resolved selection: which Fleet a session should start on, and which
584 /// scope made the choice.
585 #[derive(Debug, Clone, PartialEq, Eq)]
586 pub struct SelectedFleet {
587 pub name: String,
588 pub scope: FleetScope,
589 pub path: PathBuf,
590 }
591
592 fn personal_fleets_dir() -> Result<PathBuf, FleetStoreError> {
593 #[cfg(test)]
594 if !crate::test_support::guarded_environment_provides_state_paths() {
595 return Ok(crate::test_support::unsealed_test_state_root().join(FLEET_DIR));
596 }
597 codewhale_config::codewhale_home()
598 .map(|home| home.join(FLEET_DIR))
599 .map_err(|e| FleetStoreError::Io {
600 path: "$CODEWHALE_HOME/fleets".to_string(),
601 message: e.to_string(),
602 })
603 }
604
605 fn workspace_fleets_dir(workspace: &Path) -> PathBuf {
606 workspace.join(".codewhale").join(FLEET_DIR)
607 }
608
609 /// The fleet directory for a scope, creating it if needed.
610 fn ensure_fleets_dir(scope: FleetScope, workspace: &Path) -> Result<PathBuf, FleetStoreError> {
611 let dir = match scope {
612 FleetScope::Personal => personal_fleets_dir()?,
613 FleetScope::Workspace => workspace_fleets_dir(workspace),
614 };
615 reject_linked_workspace_dir(scope, workspace, &dir)?;
616 fs::create_dir_all(&dir).map_err(|e| FleetStoreError::Io {
617 path: dir.display().to_string(),
618 message: e.to_string(),
619 })?;
620 Ok(dir)
621 }
622
623 /// A workspace's `.codewhale/fleets` is repository content: refuse it when it,
624 /// or `.codewhale`, is a link, so saving, selecting or deleting a Fleet cannot
625 /// reach outside the workspace. The personal directory is the user's own.
626 fn reject_linked_workspace_dir(
627 scope: FleetScope,
628 workspace: &Path,
629 dir: &Path,
630 ) -> Result<(), FleetStoreError> {
631 if scope != FleetScope::Workspace {
632 return Ok(());
633 }
634 super::files::reject_linked_path(workspace, dir).map_err(|e| FleetStoreError::Io {
635 path: dir.display().to_string(),
636 message: e.to_string(),
637 })
638 }
639
640 /// List every named Fleet across both scopes, personal first. A file that is
641 /// not a v2 Fleet is listed as `legacy` with its parse error, so an old exact
642 /// fleet is visible — never silently absent — while the user decides whether
643 /// to migrate it.
644 pub fn list_fleets(workspace: &Path) -> Vec<FleetEntry> {
645 let mut entries = Vec::new();
646 if let Ok(dir) = personal_fleets_dir() {
647 collect_entries(&dir, FleetScope::Personal, &mut entries);
648 }
649 collect_entries(
650 &workspace_fleets_dir(workspace),
651 FleetScope::Workspace,
652 &mut entries,
653 );
654 entries.sort_by(|a, b| {
655 a.scope
656 .label()
657 .cmp(b.scope.label())
658 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
659 });
660 entries
661 }
662
663 fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
664 let Ok(read) = fs::read_dir(dir) else {
665 return;
666 };
667 let mut files: Vec<PathBuf> = read
668 .filter_map(|entry| entry.ok())
669 .map(|entry| entry.path())
670 .filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
671 .collect();
672 files.sort();
673 for path in files {
674 let stem = path
675 .file_stem()
676 .map(|s| s.to_string_lossy().into_owned())
677 .unwrap_or_default();
678 let text = fs::read_to_string(&path).ok();
679 let parse_error = text
680 .as_deref()
681 .and_then(|text| FleetFile::parse(text).err())
682 .map(|e| e.to_string());
683 let legacy = parse_error.as_deref().is_some_and(|err| {
684 err.contains("unknown schema") || err.contains("invalid fleet TOML")
685 });
686 // The row shows the Fleet's own display name, never the file slug —
687 // a file saved as `Temp Fleet` must not appear as `temp-fleet`.
688 let name = text
689 .as_deref()
690 .and_then(|text| toml::from_str::<toml::Value>(text).ok())
691 .and_then(|value| {
692 value
693 .get("name")
694 .and_then(|n| n.as_str())
695 .map(str::trim)
696 .filter(|n| !n.is_empty())
697 .map(str::to_string)
698 })
699 .unwrap_or(stem);
700 out.push(FleetEntry {
701 name,
702 scope,
703 path,
704 parse_error,
705 legacy,
706 });
707 }
708 }
709
710 /// Every v2 Fleet file that answers to `name`, personal first.
711 ///
712 /// Only files that declare `schema = "fleet"` count. The personal `fleets/`
713 /// directory is shared with the workflow crate's legacy/exact files, and a
714 /// file in another schema is a different Fleet form, not a v2 Fleet that
715 /// failed to parse — the caller that owns that form reports on it.
716 pub(crate) fn v2_fleet_candidates(name: &str, workspace: &Path) -> Vec<(FleetScope, PathBuf)> {
717 let file_name = format!("{}.toml", slugify(name.trim()));
718 let mut found = Vec::new();
719 let personal = personal_fleets_dir().ok().map(|dir| dir.join(&file_name));
720 let workspace = Some(workspace_fleets_dir(workspace).join(&file_name));
721 for (scope, path) in [
722 (FleetScope::Personal, personal),
723 (FleetScope::Workspace, workspace),
724 ] {
725 if let Some(path) = path
726 && path.is_file()
727 && declares_v2_schema(&path)
728 {
729 found.push((scope, path));
730 }
731 }
732 found
733 }
734
735 /// The `schema` a Fleet file declares, normalized to lowercase. `None` when
736 /// the file cannot be read; `Some(None)` when it is readable but declares no
737 /// schema (or is not valid TOML).
738 pub(crate) fn read_declared_schema(path: &Path) -> Option<Option<String>> {
739 fs::read_to_string(path)
740 .ok()
741 .map(|text| codewhale_workflow::fleet_exact::declared_schema_kind(&text))
742 }
743
744 /// Whether a file declares the v2 `schema = "fleet"`. Unreadable or
745 /// malformed TOML is not a v2 declaration.
746 pub(crate) fn declares_v2_schema(path: &Path) -> bool {
747 read_declared_schema(path).flatten().as_deref() == Some(FLEET_SCHEMA_KIND)
748 }
749
750 /// Load a v2 Fleet by name. Ambiguity between the two scopes is an error that
751 /// names both origins — the caller resolves it by asking for a scope. A file
752 /// under the same name in another schema (legacy/exact) is not a v2 hit.
753 /// Used by `workflow(fleet:)` through `fleet::exact::load_fleet_document`.
754 pub fn load_fleet(
755 name: &str,
756 workspace: &Path,
757 ) -> Result<(FleetFile, FleetScope, PathBuf), FleetStoreError> {
758 let name = name.trim();
759 if name.is_empty() {
760 return Err(FleetStoreError::NotFound("<empty name>".to_string()));
761 }
762 let mut found = v2_fleet_candidates(name, workspace);
763 if found.len() > 1 {
764 return Err(FleetStoreError::Ambiguous(
765 name.to_string(),
766 FleetScope::Personal.label().to_string(),
767 FleetScope::Workspace.label().to_string(),
768 ));
769 }
770 let Some((scope, path)) = found.pop() else {
771 return Err(FleetStoreError::NotFound(name.to_string()));
772 };
773 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
774 path: path.display().to_string(),
775 message: e.to_string(),
776 })?;
777 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
778 path: path.display().to_string(),
779 message: e.to_string(),
780 })?;
781 Ok((fleet, scope, path))
782 }
783
784 /// Load a v2 Fleet by name in one explicit scope. Unlike [`load_fleet`],
785 /// this never resolves ambiguity — the caller already knows where the Fleet
786 /// lives (e.g. the row the user just picked).
787 pub fn load_fleet_in_scope(
788 name: &str,
789 scope: FleetScope,
790 workspace: &Path,
791 ) -> Result<(FleetFile, PathBuf), FleetStoreError> {
792 let dir = match scope {
793 FleetScope::Personal => personal_fleets_dir()?,
794 FleetScope::Workspace => workspace_fleets_dir(workspace),
795 };
796 let path = dir.join(format!("{}.toml", slugify(name)));
797 if !path.is_file() {
798 return Err(FleetStoreError::NotFound(format!(
799 "{} ({})",
800 name,
801 scope.label()
802 )));
803 }
804 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
805 path: path.display().to_string(),
806 message: e.to_string(),
807 })?;
808 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
809 path: path.display().to_string(),
810 message: e.to_string(),
811 })?;
812 Ok((fleet, path))
813 }
814
815 /// Load a v2 Fleet from a specific path (used by the editor on the currently
816 /// open entry, so the saved scope is exact). API surface for the path-based
817 /// editor flows; currently exercised by tests.
818 pub fn load_fleet_at(path: &Path) -> Result<(FleetFile, FleetScope), FleetStoreError> {
819 let text = fs::read_to_string(path).map_err(|e| FleetStoreError::Io {
820 path: path.display().to_string(),
821 message: e.to_string(),
822 })?;
823 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
824 path: path.display().to_string(),
825 message: e.to_string(),
826 })?;
827 let scope = if path.starts_with(personal_fleets_dir().unwrap_or_default()) {
828 FleetScope::Personal
829 } else {
830 FleetScope::Workspace
831 };
832 Ok((fleet, scope))
833 }
834
835 /// Save a Fleet to a scope with an atomic write. Refuses to clobber a
836 /// different Fleet of the same slug (the name is the identity).
837 pub fn save_fleet(
838 fleet: &FleetFile,
839 scope: FleetScope,
840 workspace: &Path,
841 ) -> Result<PathBuf, FleetStoreError> {
842 fleet.validate()?;
843 let dir = ensure_fleets_dir(scope, workspace)?;
844 let path = dir.join(format!("{}.toml", fleet.file_slug()));
845 if path.is_file() {
846 // Only a readable v2 Fleet of this same name may be replaced. Any
847 // other non-empty file at this slug (a legacy roster or exact fleet,
848 // a Fleet from a newer build, a Fleet mid-edit that no longer parses)
849 // is left unchanged.
850 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
851 path: path.display().to_string(),
852 message: e.to_string(),
853 })?;
854 if !text.trim().is_empty() {
855 match FleetFile::parse(&text) {
856 Ok(existing) if existing.name == fleet.name => {}
857 Ok(_) => {
858 return Err(FleetStoreError::NameTaken {
859 name: fleet.name.clone(),
860 path: path.display().to_string(),
861 });
862 }
863 Err(parse_error) => {
864 let reason = if codewhale_workflow::fleet_exact::declared_schema_kind(&text)
865 .as_deref()
866 == Some(FLEET_SCHEMA_KIND)
867 {
868 format!("is a Fleet file this build cannot read ({parse_error})")
869 } else {
870 "holds another fleet file (a legacy roster or exact fleet)".to_string()
871 };
872 return Err(FleetStoreError::Invalid(format!(
873 "{} {reason}; it was left unchanged. Fix or move that file, or save `{}` under another name",
874 path.display(),
875 fleet.name
876 )));
877 }
878 }
879 }
880 }
881 let rendered = fleet.render_toml()?;
882 write_scoped(scope, workspace, &path, rendered.as_bytes())?;
883 Ok(path)
884 }
885
886 /// Delete a saved Fleet (UI confirms first). Returns the removed path.
887 pub fn delete_fleet(
888 name: &str,
889 scope: FleetScope,
890 workspace: &Path,
891 ) -> Result<PathBuf, FleetStoreError> {
892 let dir = match scope {
893 FleetScope::Personal => personal_fleets_dir()?,
894 FleetScope::Workspace => workspace_fleets_dir(workspace),
895 };
896 reject_linked_workspace_dir(scope, workspace, &dir)?;
897 let path = dir.join(format!("{}.toml", slugify(name)));
898 if !path.is_file() {
899 return Err(FleetStoreError::NotFound(name.to_string()));
900 }
901 fs::remove_file(&path).map_err(|e| FleetStoreError::Io {
902 path: path.display().to_string(),
903 message: e.to_string(),
904 })?;
905 // A selection that pointed at the deleted Fleet must not linger: it would
906 // render as a phantom selection. The write is best-effort; a leftover
907 // selection is reported by the reader as missing, never as valid.
908 clear_selection_if_matching(scope, workspace, name);
909 Ok(path)
910 }
911
912 /// The active selection: workspace selection wins, then the personal
913 /// user-global default. Each file is scope-explicit; a workspace selection
914 /// can never hide the personal Fleet — the personal default is only overridden
915 /// for this folder, visibly.
916 pub fn resolve_selected_fleet(workspace: &Path) -> Result<Option<SelectedFleet>, FleetStoreError> {
917 let ws_dir = workspace_fleets_dir(workspace);
918 if let Some(name) = read_selection_result(&ws_dir)? {
919 // A workspace selection may name a personal Fleet (selected for this
920 // folder only): resolve workspace first, then personal, and report
921 // the scope the Fleet actually lives in.
922 let ws_path = ws_dir.join(format!("{}.toml", slugify(&name)));
923 if ws_path.is_file() {
924 return Ok(Some(SelectedFleet {
925 name,
926 scope: FleetScope::Workspace,
927 path: ws_path,
928 }));
929 }
930 if let Ok(dir) = personal_fleets_dir() {
931 let personal_path = dir.join(format!("{}.toml", slugify(&name)));
932 if personal_path.is_file() {
933 return Ok(Some(SelectedFleet {
934 name,
935 scope: FleetScope::Personal,
936 path: personal_path,
937 }));
938 }
939 }
940 return Err(FleetStoreError::NotFound(format!(
941 "selected fleet `{name}` (folder selection at {})",
942 ws_dir.join(SELECTED_FILE).display()
943 )));
944 }
945 if let Ok(dir) = personal_fleets_dir()
946 && let Some(name) = read_selection_result(&dir)?
947 {
948 let path = dir.join(format!("{}.toml", slugify(&name)));
949 if path.is_file() {
950 return Ok(Some(SelectedFleet {
951 name,
952 scope: FleetScope::Personal,
953 path,
954 }));
955 }
956 return Err(FleetStoreError::NotFound(format!(
957 "selected fleet `{name}` (user selection at {})",
958 dir.join(SELECTED_FILE).display()
959 )));
960 }
961 Ok(None)
962 }
963
964 /// Compatibility projection for display-only callers. Runtime callers must
965 /// use [`resolve_selected_fleet`] so a broken explicit selection cannot be
966 /// mistaken for "no selection" and silently fall back to legacy profiles.
967 #[must_use]
968 pub fn selected_fleet(workspace: &Path) -> Option<SelectedFleet> {
969 resolve_selected_fleet(workspace).ok().flatten()
970 }
971
972 fn read_selection_result(dir: &Path) -> Result<Option<String>, FleetStoreError> {
973 let path = dir.join(SELECTED_FILE);
974 let text = match fs::read_to_string(&path) {
975 Ok(text) => text,
976 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
977 Err(error) => {
978 return Err(FleetStoreError::Io {
979 path: path.display().to_string(),
980 message: error.to_string(),
981 });
982 }
983 };
984 let name = text.trim();
985 if name.is_empty() {
986 Ok(None)
987 } else {
988 Ok(Some(name.to_string()))
989 }
990 }
991
992 fn read_selection(dir: &Path) -> Option<String> {
993 read_selection_result(dir).ok().flatten()
994 }
995
996 /// Write the selection for a scope. Returns the exact file written.
997 ///
998 /// The selection file lives in the scope's `fleets/` directory, but the
999 /// Fleet it names may live in either scope: a workspace selection may point
1000 /// at a personal Fleet (selecting it for this folder only), and a personal
1001 /// selection always points at a personal Fleet. The validation only refuses
1002 /// a name that exists NOWHERE — a phantom selection would be a lie.
1003 pub fn set_selected(
1004 name: &str,
1005 scope: FleetScope,
1006 workspace: &Path,
1007 ) -> Result<PathBuf, FleetStoreError> {
1008 let dir = ensure_fleets_dir(scope, workspace)?;
1009 let name = name.trim();
1010 let exists_in_scope = |target: FleetScope| {
1011 let target_dir = match target {
1012 FleetScope::Personal => personal_fleets_dir().ok(),
1013 FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
1014 };
1015 target_dir
1016 .map(|d| d.join(format!("{}.toml", slugify(name))).is_file())
1017 .unwrap_or(false)
1018 };
1019 let exists = exists_in_scope(scope) || exists_in_scope(FleetScope::Personal);
1020 if !exists {
1021 return Err(FleetStoreError::NotFound(format!(
1022 "{} ({})",
1023 name,
1024 scope.label()
1025 )));
1026 }
1027 let selected = dir.join(SELECTED_FILE);
1028 write_scoped(scope, workspace, &selected, name.as_bytes())?;
1029 Ok(selected)
1030 }
1031
1032 fn clear_selection_if_matching(scope: FleetScope, workspace: &Path, name: &str) {
1033 let dir = match scope {
1034 FleetScope::Personal => personal_fleets_dir().ok(),
1035 FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
1036 };
1037 let Some(dir) = dir else { return };
1038 if reject_linked_workspace_dir(scope, workspace, &dir).is_err() {
1039 return;
1040 }
1041 let selected = dir.join(SELECTED_FILE);
1042 if read_selection(&dir).as_deref() == Some(name.trim()) {
1043 let _ = fs::remove_file(selected);
1044 }
1045 }
1046
1047 /// Write a Fleet or selection file for `scope`. A workspace file goes through
1048 /// the pinned no-follow writer (parents created without following a link, the
1049 /// final name replaced, never written through); the personal scope is the
1050 /// user's own directory and keeps the plain atomic write.
1051 fn write_scoped(
1052 scope: FleetScope,
1053 workspace: &Path,
1054 path: &Path,
1055 bytes: &[u8],
1056 ) -> Result<(), FleetStoreError> {
1057 if scope != FleetScope::Workspace {
1058 return atomic_write(path, bytes);
1059 }
1060 let io_error = |e: std::io::Error| FleetStoreError::Io {
1061 path: path.display().to_string(),
1062 message: e.to_string(),
1063 };
1064 let relative = path
1065 .strip_prefix(workspace)
1066 .map_err(|_| FleetStoreError::Io {
1067 path: path.display().to_string(),
1068 message: "a workspace Fleet file must stay within the workspace".to_string(),
1069 })?;
1070 super::files::WorkspaceFile::open_shared(workspace, relative, true)
1071 .and_then(|file| file.replace(bytes))
1072 .map_err(io_error)
1073 }
1074
1075 /// Atomic write: temp file in the same directory, then rename. A failed write
1076 /// never leaves a half-written Fleet or selection.
1077 /// Each write gets its own temp file, so two concurrent savers can never
1078 /// interleave bytes into one shared temp and publish a mixed file.
1079 fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), FleetStoreError> {
1080 crate::utils::write_atomic_workspace(path, bytes).map_err(|e| FleetStoreError::Io {
1081 path: path.display().to_string(),
1082 message: e.to_string(),
1083 })
1084 }
1085
1086 /// One row of the migration receipt: how a legacy role profile maps into the
1087 /// new Fleet.
1088 #[derive(Debug, Clone, PartialEq, Eq)]
1089 pub struct MigrationRow {
1090 /// Role id, e.g. `scout`.
1091 pub id: String,
1092 /// The pin that will be saved (model + provider, or "inherit").
1093 pub pin: Option<(String, String)>,
1094 /// The winning origin under the legacy precedence.
1095 pub winner: String,
1096 /// A lower-precedence copy with identical content — not a conflict.
1097 pub identical_shadow: Option<String>,
1098 /// A lower-precedence copy that differed and was NOT carried over.
1099 pub conflicting_shadow: Option<String>,
1100 }
1101
1102 /// The result of migrating the legacy per-role roster into a v2 Fleet.
1103 #[derive(Debug, Clone, PartialEq, Eq)]
1104 pub struct MigrationReceipt {
1105 /// The Fleet that was (or would be) saved.
1106 pub fleet: FleetFile,
1107 /// Per-role mapping, including every conflict that was resolved.
1108 pub rows: Vec<MigrationRow>,
1109 /// Path the Fleet was saved to.
1110 pub saved_to: PathBuf,
1111 }
1112
1113 /// Build (and optionally save) a v2 Fleet from the legacy per-role roster:
1114 /// built-ins + `[fleet.profiles]` + personal + workspace profile files.
1115 ///
1116 /// Nothing is discarded: every role becomes a member, every pin survives, and
1117 /// each lower-precedence copy that differed is named in the receipt. The
1118 /// legacy files themselves are left untouched — they become migration input,
1119 /// not live config, once a Fleet is selected.
1120 pub fn migrate_legacy_roster(
1121 fleet_config: &codewhale_config::FleetConfigToml,
1122 workspace: &Path,
1123 save: bool,
1124 save_scope: FleetScope,
1125 ) -> Result<MigrationReceipt, FleetStoreError> {
1126 let roster = FleetRoster::load(fleet_config, workspace);
1127 let mut fleet = FleetFile::new(
1128 "Default".to_string(),
1129 Some("Migrated from the legacy per-role profile configuration.".to_string()),
1130 )?;
1131 let mut rows = Vec::new();
1132 for member in roster.members() {
1133 let profile = &member.profile;
1134 let (model, provider) = match (&profile.model, &profile.provider) {
1135 (Some(model), Some(provider)) => (Some(model.clone()), Some(provider.clone())),
1136 _ => (None, None),
1137 };
1138 // Legacy profiles carry no capability requirements; a migration
1139 // never invents one. Requirements start empty in the v2 Fleet.
1140 let requires: Vec<String> = Vec::new();
1141 let row = MigrationRow {
1142 id: member.id.clone(),
1143 pin: model
1144 .as_ref()
1145 .map(|m| (m.clone(), provider.clone().unwrap_or_default())),
1146 winner: member.origin.to_string(),
1147 identical_shadow: None,
1148 conflicting_shadow: None,
1149 };
1150 // Record shadowed copies (the roster already resolved them; here we
1151 // name them so the conflict is visible before anyone accepts it).
1152 let shadows: Vec<String> = roster
1153 .shadowed()
1154 .iter()
1155 .filter(|s| s.id == member.id)
1156 .map(|s| {
1157 format!(
1158 "{} copy at {} ignored in favor of {}",
1159 s.shadowed_origin,
1160 s.shadowed_source.display(),
1161 s.winner_origin
1162 )
1163 })
1164 .collect();
1165 let mut row = row;
1166 if let Some(first) = shadows.first() {
1167 if shadows.len() == 1 && first.contains("built-in") {
1168 row.identical_shadow = Some(first.clone());
1169 } else {
1170 row.conflicting_shadow = Some(shadows.join("; "));
1171 }
1172 }
1173 rows.push(row);
1174 fleet.members.push(FleetMember {
1175 id: member.id.clone(),
1176 display_name: member.display_name.clone(),
1177 shortlist: false,
1178 role: profile.role.name.clone(),
1179 model,
1180 provider,
1181 reasoning: profile.reasoning_effort.clone(),
1182 instructions: profile.role.instructions.clone(),
1183 requires,
1184 });
1185 }
1186 fleet.validate()?;
1187 let saved_to = if save {
1188 save_fleet(&fleet, save_scope, workspace)?
1189 } else {
1190 match save_scope {
1191 FleetScope::Personal => {
1192 personal_fleets_dir()?.join(format!("{}.toml", fleet.file_slug()))
1193 }
1194 FleetScope::Workspace => {
1195 workspace_fleets_dir(workspace).join(format!("{}.toml", fleet.file_slug()))
1196 }
1197 }
1198 };
1199 Ok(MigrationReceipt {
1200 fleet,
1201 rows,
1202 saved_to,
1203 })
1204 }
1205
1206 #[cfg(test)]
1207 mod tests {
1208 use super::*;
1209 use std::sync::OnceLock;
1210
1211 /// A sealed CODEWHALE_HOME for personal-scope tests, created once per
1212 /// process. Tests must still hold `lock_test_env` before touching it.
1213 fn sealed_home() -> &'static Path {
1214 static HOME: OnceLock<PathBuf> = OnceLock::new();
1215 HOME.get_or_init(|| {
1216 let dir = tempfile::TempDir::new()
1217 .expect("temp dir for sealed home")
1218 .keep();
1219 std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
1220 dir
1221 })
1222 }
1223
1224 /// Point CODEWHALE_HOME at a sealed temp dir. Caller must hold
1225 /// `lock_test_env`.
1226 fn set_sealed_home() -> crate::test_support::EnvVarGuard {
1227 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", sealed_home())
1228 }
1229
1230 /// A workspace's `.codewhale/fleets` is repository content. Saving,
1231 /// selecting and deleting refuse it when it is a link, and a link at the
1232 /// Fleet's own file name is replaced rather than written through.
1233 #[cfg(unix)]
1234 #[test]
1235 fn workspace_fleet_writes_never_follow_links_out_of_the_workspace() {
1236 use std::os::unix::fs::symlink;
1237 let _lock = crate::test_support::lock_test_env();
1238 let _home = set_sealed_home();
1239 let outside = tempfile::tempdir().unwrap();
1240
1241 // `.codewhale/fleets` linked: save, select and delete are all refused.
1242 let linked = tempfile::tempdir().unwrap();
1243 std::fs::create_dir_all(linked.path().join(".codewhale")).unwrap();
1244 symlink(
1245 outside.path(),
1246 linked.path().join(".codewhale").join(FLEET_DIR),
1247 )
1248 .unwrap();
1249 let fleet = sample_fleet();
1250 assert!(save_fleet(&fleet, FleetScope::Workspace, linked.path()).is_err());
1251 assert!(set_selected("DeepSeek Flash", FleetScope::Workspace, linked.path()).is_err());
1252 assert!(delete_fleet("DeepSeek Flash", FleetScope::Workspace, linked.path()).is_err());
1253 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
1254
1255 // A link at the Fleet's file name is replaced, never written through.
1256 let plain = tempfile::tempdir().unwrap();
1257 let dir = plain.path().join(".codewhale").join(FLEET_DIR);
1258 std::fs::create_dir_all(&dir).unwrap();
1259 let victim = outside.path().join("victim.toml");
1260 std::fs::write(&victim, "keep me").unwrap();
1261 symlink(&victim, dir.join(format!("{}.toml", fleet.file_slug()))).unwrap();
1262 let saved = save_fleet(&fleet, FleetScope::Workspace, plain.path());
1263 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep me");
1264 if let Ok(path) = saved {
1265 assert!(
1266 !std::fs::symlink_metadata(&path)
1267 .unwrap()
1268 .file_type()
1269 .is_symlink()
1270 );
1271 }
1272 }
1273
1274 #[test]
1275 fn declared_schema_separates_unreadable_from_undeclared() {
1276 let dir = tempfile::tempdir().unwrap();
1277 let v2 = dir.path().join("v2.toml");
1278 std::fs::write(&v2, "name = \"a\"\nschema = \" Fleet \"\n").unwrap();
1279 let legacy = dir.path().join("legacy.toml");
1280 std::fs::write(&legacy, "name = \"b\"\n[roles]\nscout = \"scout\"\n").unwrap();
1281 let malformed = dir.path().join("bad.toml");
1282 std::fs::write(&malformed, "schema = [").unwrap();
1283 let missing = dir.path().join("missing.toml");
1284
1285 assert!(
1286 declares_v2_schema(&v2),
1287 "case and whitespace are normalized"
1288 );
1289 assert_eq!(read_declared_schema(&legacy), Some(None));
1290 assert_eq!(read_declared_schema(&malformed), Some(None));
1291 assert_eq!(read_declared_schema(&missing), None);
1292 assert!(!declares_v2_schema(&legacy));
1293 assert!(!declares_v2_schema(&malformed));
1294 assert!(!declares_v2_schema(&missing));
1295 }
1296
1297 #[test]
1298 fn unsealed_personal_routes_ignore_ambient_home() {
1299 const PROBE: &str = "CODEWHALE_TEST_AMBIENT_FLEET_PROBE";
1300 if std::env::var_os(PROBE).is_some() {
1301 let workspace = tempfile::tempdir().unwrap();
1302 for hold_env_lock in [false, true] {
1303 let _lock = hold_env_lock.then(crate::test_support::lock_test_env);
1304 let root = crate::test_support::unsealed_test_state_root();
1305 assert_eq!(personal_fleets_dir().unwrap(), root.join(FLEET_DIR));
1306 assert_eq!(
1307 crate::fleet::profile::personal_agent_profile_dir().unwrap(),
1308 root.join("agents")
1309 );
1310 assert!(resolve_selected_fleet(workspace.path()).unwrap().is_none());
1311 assert!(list_fleets(workspace.path()).is_empty());
1312 let roster = crate::fleet::identity::load_effective_roster(
1313 &Default::default(),
1314 workspace.path(),
1315 None,
1316 );
1317 assert!(roster.load_error().is_none());
1318 assert!(roster.members().iter().all(|member| {
1319 member.origin == crate::fleet::roster::ProfileOrigin::BuiltIn
1320 }));
1321 }
1322 return;
1323 }
1324
1325 // A fresh process inherits populated operator state, without earning
1326 // the explicit EnvVarGuard seal used by deliberate path fixtures.
1327 let ambient = tempfile::tempdir().unwrap();
1328 let state = ambient.path().join(".codewhale");
1329 let fleets = state.join(FLEET_DIR);
1330 std::fs::create_dir_all(&fleets).unwrap();
1331 let fleet = sample_fleet();
1332 let fleet_path = fleets.join(format!("{}.toml", fleet.file_slug()));
1333 let contents = fleet.render_toml().unwrap();
1334 std::fs::write(&fleet_path, &contents).unwrap();
1335 std::fs::write(fleets.join(SELECTED_FILE), &fleet.name).unwrap();
1336 for explicit_override in [false, true] {
1337 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1338 command
1339 .args([
1340 "--exact",
1341 "fleet::store::tests::unsealed_personal_routes_ignore_ambient_home",
1342 "--test-threads=1",
1343 ])
1344 .env(PROBE, "1")
1345 .env("HOME", ambient.path())
1346 .env("USERPROFILE", ambient.path())
1347 .env_remove("CODEWHALE_HOME")
1348 .env_remove("CODEWHALE_CONFIG_PATH")
1349 .env_remove("DEEPSEEK_CONFIG_PATH");
1350 if explicit_override {
1351 command.env("CODEWHALE_HOME", &state);
1352 }
1353 let output = command.output().unwrap();
1354 assert!(
1355 output.status.success(),
1356 "ambient route probe failed (override={explicit_override})\n{}\n{}",
1357 String::from_utf8_lossy(&output.stdout),
1358 String::from_utf8_lossy(&output.stderr)
1359 );
1360 }
1361 assert_eq!(std::fs::read_to_string(fleet_path).unwrap(), contents);
1362 assert_eq!(
1363 std::fs::read_to_string(fleets.join(SELECTED_FILE)).unwrap(),
1364 fleet.name
1365 );
1366 }
1367
1368 fn sample_fleet() -> FleetFile {
1369 FleetFile::new("DeepSeek Flash".to_string(), None)
1370 .expect("valid fleet")
1371 .with_operator(FleetOperator {
1372 provider: "deepseek".to_string(),
1373 model: "deepseek-v4-flash".to_string(),
1374 reasoning: Some("low".to_string()),
1375 })
1376 .with_member(FleetMember {
1377 id: "scout".to_string(),
1378 display_name: Some("Flash Scout".to_string()),
1379 shortlist: false,
1380 role: "scout".to_string(),
1381 provider: None,
1382 model: None,
1383 reasoning: None,
1384 instructions: None,
1385 requires: Vec::new(),
1386 })
1387 .with_member(FleetMember {
1388 id: "builder".to_string(),
1389 display_name: None,
1390 shortlist: false,
1391 role: "builder".to_string(),
1392 provider: Some("deepseek".to_string()),
1393 model: Some("deepseek-v4-pro".to_string()),
1394 reasoning: Some("high".to_string()),
1395 instructions: Some("Implement exactly the task slice.".to_string()),
1396 requires: vec!["vision".to_string()],
1397 })
1398 }
1399
1400 trait FleetBuilder {
1401 fn with_operator(self, operator: FleetOperator) -> Self;
1402 fn with_member(self, member: FleetMember) -> Self;
1403 }
1404
1405 impl FleetBuilder for FleetFile {
1406 fn with_operator(mut self, operator: FleetOperator) -> Self {
1407 self.operator = Some(operator);
1408 self
1409 }
1410 fn with_member(mut self, member: FleetMember) -> Self {
1411 self.members.push(member);
1412 self
1413 }
1414 }
1415
1416 #[test]
1417 fn validation_rejects_bad_documents_with_specific_errors() {
1418 let _lock = crate::test_support::lock_test_env();
1419
1420 // Empty name.
1421 let err = FleetFile::new(" ".to_string(), None).unwrap_err();
1422 assert!(err.to_string().contains("name must not be empty"), "{err}");
1423
1424 // Duplicate member ids.
1425 let mut fleet = sample_fleet();
1426 fleet.members.push(fleet.members[0].clone());
1427 let err = fleet.validate().unwrap_err();
1428 assert!(
1429 err.to_string().contains("duplicate member id `scout`"),
1430 "{err}"
1431 );
1432
1433 // Dispatch identity is case-insensitive, so validation must reject a
1434 // pair lookup could not distinguish.
1435 let mut fleet = sample_fleet();
1436 let mut duplicate = fleet.members[0].clone();
1437 duplicate.id = "SCOUT".to_string();
1438 fleet.members.push(duplicate);
1439 let err = fleet.validate().unwrap_err();
1440 assert!(err.to_string().contains("case-insensitively"), "{err}");
1441
1442 // Human-facing names stay bounded and single-line before they can
1443 // enter selectors, roster discovery, or terminal rendering.
1444 let mut fleet = sample_fleet();
1445 fleet.members[0].display_name = Some("x".repeat(MAX_MEMBER_DISPLAY_NAME_CHARS + 1));
1446 let err = fleet.validate().unwrap_err();
1447 assert!(err.to_string().contains("no longer than 80"), "{err}");
1448 let mut fleet = sample_fleet();
1449 fleet.members[0].display_name = Some("Flash\nScout".to_string());
1450 let err = fleet.validate().unwrap_err();
1451 assert!(
1452 err.to_string().contains("one trimmed printable line"),
1453 "{err}"
1454 );
1455
1456 // Identity and route values are bounded and control-free too; only
1457 // the instruction overlay may span lines.
1458 let mut fleet = sample_fleet();
1459 fleet.members[0].id = "scout\u{1b}[2J".to_string();
1460 let err = fleet.validate().unwrap_err();
1461 assert!(
1462 err.to_string().contains("member id must be printable"),
1463 "{err}"
1464 );
1465 let mut fleet = sample_fleet();
1466 fleet.members[0].id = "x".repeat(MAX_MEMBER_ID_CHARS + 1);
1467 assert!(fleet.validate().is_err());
1468 let mut fleet = sample_fleet();
1469 fleet.members[0].id = format!("{} ", fleet.members[0].id);
1470 assert!(
1471 fleet.validate().is_err(),
1472 "an untrimmed id is not its trimmed twin"
1473 );
1474 let mut fleet = sample_fleet();
1475 fleet.name = "Team\u{7}".to_string();
1476 assert!(fleet.validate().is_err());
1477 // F01-04: the rest of the document's free text is bounded too.
1478 let mut fleet = sample_fleet();
1479 fleet.name = "Team\n".to_string();
1480 assert!(
1481 fleet.validate().is_err(),
1482 "a trailing control is still a control"
1483 );
1484 let mut fleet = sample_fleet();
1485 fleet.description = Some("about\u{7}".to_string());
1486 assert!(fleet.validate().is_err());
1487 fleet.description = Some("line one\nline two".to_string());
1488 fleet.validate().expect("a description may span lines");
1489 fleet.description = Some("x".repeat(MAX_FLEET_DESCRIPTION_CHARS + 1));
1490 assert!(fleet.validate().is_err());
1491 let mut fleet = sample_fleet();
1492 fleet.operator = Some(FleetOperator {
1493 provider: "deepseek".to_string(),
1494 model: "deepseek-v4\u{1b}[2J".to_string(),
1495 reasoning: None,
1496 });
1497 let err = fleet.validate().unwrap_err();
1498 assert!(err.to_string().contains("operator model"), "{err}");
1499 let mut fleet = sample_fleet();
1500 fleet.members[0].instructions = Some("line one\nline two\ttabbed".to_string());
1501 fleet.validate().expect("instructions may span lines");
1502 fleet.members[0].instructions = Some("x".repeat(MAX_MEMBER_INSTRUCTIONS_CHARS + 1));
1503 assert!(fleet.validate().is_err());
1504
1505 // Lone provider / lone model: never silently reinterpreted.
1506 let mut fleet = sample_fleet();
1507 fleet.members[0].provider = Some("deepseek".to_string());
1508 let err = fleet.validate().unwrap_err();
1509 assert!(
1510 err.to_string().contains("must pin both provider and model"),
1511 "{err}"
1512 );
1513 let mut fleet = sample_fleet();
1514 fleet.members[0].model = Some("deepseek-v4-pro".to_string());
1515 let err = fleet.validate().unwrap_err();
1516 assert!(
1517 err.to_string().contains("must pin both provider and model"),
1518 "{err}"
1519 );
1520
1521 // Unknown capability requirement.
1522 let mut fleet = sample_fleet();
1523 fleet.members[0].requires = vec!["telepathy".to_string()];
1524 let err = fleet.validate().unwrap_err();
1525 assert!(
1526 err.to_string().contains("unknown capability `telepathy`"),
1527 "{err}"
1528 );
1529 assert!(err.to_string().contains("vision"), "{err}");
1530 }
1531
1532 #[test]
1533 fn member_pin_matching_the_operator_route_reads_as_inheritance() {
1534 // #6037: a role member pinned to the fleet's own operator route
1535 // resolves to that route either way — the pin only stops it
1536 // following when the operator moves. Parse drops the redundant pin;
1537 // a different-route pin and a shortlist row keep theirs.
1538 let fleet = FleetFile::parse(
1539 r#"schema = "fleet"
1540 schema_revision = 2
1541 name = "Inherit"
1542
1543 [operator]
1544 provider = "openrouter"
1545 model = "z-ai/glm-5.3"
1546
1547 [[members]]
1548 id = "planner"
1549 role = "planner"
1550 provider = "openrouter"
1551 model = "z-ai/glm-5.3"
1552
1553 [[members]]
1554 id = "builder"
1555 role = "builder"
1556 provider = "openrouter"
1557 model = "z-ai/glm-5.3-pro"
1558
1559 [[members]]
1560 id = "choice"
1561 shortlist = true
1562 provider = "openrouter"
1563 model = "z-ai/glm-5.3"
1564 "#,
1565 )
1566 .expect("parse");
1567 let planner = fleet.member("planner").expect("planner member");
1568 assert_eq!(planner.provider, None);
1569 assert_eq!(planner.model, None);
1570 let builder = fleet.member("builder").expect("builder member");
1571 assert_eq!(builder.provider.as_deref(), Some("openrouter"));
1572 assert_eq!(builder.model.as_deref(), Some("z-ai/glm-5.3-pro"));
1573 let choice = fleet
1574 .members
1575 .iter()
1576 .find(|member| member.shortlist)
1577 .expect("shortlist row");
1578 assert_eq!(choice.provider.as_deref(), Some("openrouter"));
1579 assert_eq!(choice.model.as_deref(), Some("z-ai/glm-5.3"));
1580 // The listing still attributes the inherited role to the route it runs.
1581 let models = crate::fleet::members::models_of(&fleet);
1582 assert_eq!(models[0].model, "z-ai/glm-5.3");
1583 assert_eq!(models[0].roles, ["operator", "planner"]);
1584 assert_eq!(models[1].roles, ["builder"]);
1585 // The cleaned document round-trips: inherit stays inherit.
1586 assert_eq!(
1587 FleetFile::parse(&fleet.render_toml().expect("render")).expect("reparse"),
1588 fleet
1589 );
1590 }
1591
1592 #[test]
1593 fn render_parse_round_trip_preserves_every_field() {
1594 let fleet = sample_fleet();
1595 let text = fleet.render_toml().expect("render");
1596 let parsed = FleetFile::parse(&text).expect("parse");
1597 assert_eq!(parsed, fleet);
1598 assert!(text.contains("schema = \"fleet\""));
1599 assert!(text.contains("schema_revision = 2"));
1600 assert!(text.contains("display_name = \"Flash Scout\""));
1601 assert!(text.contains("deepseek-v4-flash"));
1602 assert!(
1603 !text.contains("shortlist"),
1604 "legacy members do not gain a marker"
1605 );
1606 }
1607
1608 #[test]
1609 fn marked_shortlist_round_trips_without_becoming_a_role_or_legacy_bare_pin() {
1610 let fleet = FleetFile::parse(
1611 r#"schema = "fleet"
1612 schema_revision = 2
1613 name = "Shortlist"
1614
1615 [[members]]
1616 id = "scout"
1617 shortlist = true
1618 provider = "fixture-provider"
1619 model = "scout"
1620 "#,
1621 )
1622 .expect("explicitly marked model survives legacy bare-pin migration");
1623 assert_eq!(fleet.members.len(), 1);
1624 let choice = &fleet.members[0];
1625 assert!(choice.shortlist);
1626 assert_eq!(choice.provider.as_deref(), Some("fixture-provider"));
1627 assert_eq!(choice.model.as_deref(), Some("scout"));
1628 assert!(choice.role_label().is_empty());
1629 assert!(
1630 !fleet.has_scout(),
1631 "a model named scout cannot select the scout role"
1632 );
1633 assert!(fleet.member("scout").is_none());
1634 let models = crate::fleet::members::models_of(&fleet);
1635 assert_eq!(models.len(), 1);
1636 assert!(models[0].roles.is_empty());
1637 let text = fleet.render_toml().expect("serialize marker");
1638 assert!(text.contains("shortlist = true"));
1639 assert!(!text.contains("role ="));
1640 assert_eq!(FleetFile::parse(&text).expect("reload marker"), fleet);
1641 }
1642
1643 #[test]
1644 fn shortlist_marker_rejects_roles_inheritance_and_incomplete_routes() {
1645 for (role, provider, model) in [
1646 ("scout", Some("deepseek"), Some("deepseek-v4-flash")),
1647 ("", None, None),
1648 ("", None, Some("deepseek-v4-flash")),
1649 ("", Some("deepseek"), None),
1650 ("", Some(" "), Some("deepseek-v4-flash")),
1651 ("", Some("deepseek"), Some(" ")),
1652 ] {
1653 let member: FleetMember = serde_json::from_value(serde_json::json!({
1654 "id": "choice", "shortlist": true, "role": role,
1655 "provider": provider, "model": model,
1656 }))
1657 .expect("typed fixture");
1658 let mut fleet = FleetFile::new("Malformed shortlist".into(), None).unwrap();
1659 fleet.members.push(member);
1660 assert!(
1661 fleet.validate().is_err(),
1662 "accepted invalid marker: {fleet:?}"
1663 );
1664 assert!(
1665 fleet.render_toml().is_err(),
1666 "render accepted invalid marker"
1667 );
1668 let text = toml::to_string(&fleet).expect("unchecked fixture serialization");
1669 assert!(
1670 FleetFile::parse(&text).is_err(),
1671 "parse accepted invalid marker: {text}"
1672 );
1673 }
1674
1675 for metadata in [
1676 serde_json::json!({"reasoning": "high"}),
1677 serde_json::json!({"instructions": "Review the changes."}),
1678 serde_json::json!({"requires": ["vision"]}),
1679 ] {
1680 let mut row = serde_json::json!({
1681 "id": "choice", "shortlist": true,
1682 "provider": "deepseek", "model": "deepseek-v4-flash",
1683 });
1684 row.as_object_mut()
1685 .unwrap()
1686 .extend(metadata.as_object().unwrap().clone());
1687 let mut fleet = FleetFile::new("Malformed shortlist".into(), None).unwrap();
1688 fleet.members.push(serde_json::from_value(row).unwrap());
1689 let text = toml::to_string(&fleet).expect("unchecked metadata fixture");
1690 for error in [
1691 fleet.validate().unwrap_err(),
1692 fleet.render_toml().unwrap_err(),
1693 FleetFile::parse(&text).unwrap_err(),
1694 ] {
1695 assert!(
1696 error.to_string().contains("cannot set role reasoning"),
1697 "metadata was not rejected as role-only: {error}"
1698 );
1699 }
1700 }
1701 }
1702
1703 #[test]
1704 fn member_name_alias_is_accepted_and_old_files_remain_valid() {
1705 let aliased = FleetFile::parse(
1706 r#"schema = "fleet"
1707 schema_revision = 2
1708 name = "Named"
1709
1710 [[members]]
1711 id = "scout"
1712 name = "Scout One"
1713 role = "scout"
1714 "#,
1715 )
1716 .expect("name alias");
1717 assert_eq!(
1718 aliased.members[0].display_name.as_deref(),
1719 Some("Scout One")
1720 );
1721 let canonical = aliased.render_toml().expect("canonical render");
1722 assert!(canonical.contains("display_name = \"Scout One\""));
1723
1724 let without_name = FleetFile::parse(
1725 r#"schema = "fleet"
1726 schema_revision = 2
1727 name = "Existing"
1728
1729 [[members]]
1730 id = "scout"
1731 role = "scout"
1732 "#,
1733 )
1734 .expect("pre-display-name revision-2 file");
1735 assert!(without_name.members[0].display_name.is_none());
1736 }
1737
1738 #[test]
1739 fn save_load_round_trips_in_workspace_scope() {
1740 let _lock = crate::test_support::lock_test_env();
1741 let ws = tempfile::TempDir::new().unwrap();
1742 let fleet = sample_fleet();
1743 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
1744 assert!(
1745 path.ends_with(".codewhale/fleets/deepseek-flash.toml"),
1746 "{path:?}"
1747 );
1748
1749 let (loaded, scope, path) = load_fleet("DeepSeek Flash", ws.path()).expect("load");
1750 assert_eq!(loaded, fleet);
1751 assert_eq!(scope, FleetScope::Workspace);
1752 assert_eq!(path, load_fleet("DeepSeek Flash", ws.path()).unwrap().2);
1753
1754 // A same-name Fleet in the personal scope makes the bare name
1755 // ambiguous — the reader names both origins instead of shadowing.
1756 let _home = set_sealed_home();
1757 save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal");
1758 let err = load_fleet("DeepSeek Flash", ws.path()).unwrap_err();
1759 let msg = err.to_string();
1760 assert!(msg.contains("defined in both"), "{msg}");
1761 assert!(msg.contains("user") && msg.contains("folder"), "{msg}");
1762 }
1763
1764 #[test]
1765 fn selection_is_scope_explicit_and_workspace_wins() {
1766 let _lock = crate::test_support::lock_test_env();
1767 let _home = set_sealed_home();
1768 let ws = tempfile::TempDir::new().unwrap();
1769 let fleet = sample_fleet();
1770
1771 // No selection yet.
1772 assert!(selected_fleet(ws.path()).is_none());
1773
1774 // Personal selection: the user-global default.
1775 save_fleet(&fleet, FleetScope::Personal, ws.path()).unwrap();
1776 let selected_file =
1777 set_selected("DeepSeek Flash", FleetScope::Personal, ws.path()).expect("select");
1778 assert!(
1779 selected_file.ends_with("fleets/selected"),
1780 "{selected_file:?}"
1781 );
1782 let sel = selected_fleet(ws.path()).expect("selected");
1783 assert_eq!(sel.scope, FleetScope::Personal);
1784 assert_eq!(sel.name, "DeepSeek Flash");
1785
1786 // A selection naming a missing Fleet is refused — a phantom selection
1787 // would be a lie.
1788 let err = set_selected("No Such Fleet", FleetScope::Personal, ws.path()).unwrap_err();
1789 assert!(err.to_string().contains("No Such Fleet"), "{err}");
1790
1791 // Workspace selection overrides for this folder only.
1792 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1793 set_selected("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
1794 let sel = selected_fleet(ws.path()).expect("selected");
1795 assert_eq!(sel.scope, FleetScope::Workspace);
1796 // Deleting the workspace Fleet clears the workspace selection; the
1797 // personal default reappears rather than a phantom.
1798 delete_fleet("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
1799 let sel = selected_fleet(ws.path()).expect("personal default returns");
1800 assert_eq!(sel.scope, FleetScope::Personal);
1801 }
1802
1803 #[test]
1804 fn stale_explicit_selection_is_an_error_not_legacy_fallback() {
1805 let _lock = crate::test_support::lock_test_env();
1806 let _home = set_sealed_home();
1807 let ws = tempfile::TempDir::new().unwrap();
1808 let dir = workspace_fleets_dir(ws.path());
1809 std::fs::create_dir_all(&dir).unwrap();
1810 std::fs::write(dir.join(SELECTED_FILE), "Missing Fleet\n").unwrap();
1811
1812 let error = resolve_selected_fleet(ws.path()).expect_err("stale selection must fail");
1813 assert!(
1814 error.to_string().contains("selected fleet `Missing Fleet`"),
1815 "{error}"
1816 );
1817 assert!(error.to_string().contains("folder selection"), "{error}");
1818 }
1819
1820 #[test]
1821 fn list_marks_legacy_files_without_hiding_them() {
1822 let _lock = crate::test_support::lock_test_env();
1823 let _home = set_sealed_home();
1824 let ws = tempfile::TempDir::new().unwrap();
1825
1826 save_fleet(&sample_fleet(), FleetScope::Personal, ws.path()).unwrap();
1827 // A legacy exact fleet file (workflow schema) in the same directory
1828 // must be listed as legacy, never silently absent.
1829 let legacy = r#"schema = "exact"
1830 schema_revision = 1
1831 name = "stopship"
1832 members = []"#;
1833 std::fs::write(sealed_home().join("fleets/stopship.toml"), legacy).unwrap();
1834
1835 let entries = list_fleets(ws.path());
1836 assert_eq!(entries.len(), 2, "{entries:?}");
1837 let stopship = entries
1838 .iter()
1839 .find(|e| e.name == "stopship")
1840 .expect("legacy fleet listed");
1841 assert!(stopship.legacy, "{stopship:?}");
1842 assert!(stopship.parse_error.is_some(), "{stopship:?}");
1843 let flash = entries.iter().find(|e| e.name == "DeepSeek Flash").unwrap();
1844 assert!(!flash.legacy && flash.parse_error.is_none(), "{flash:?}");
1845 }
1846
1847 #[test]
1848 fn save_refuses_to_clobber_a_different_fleet_of_the_same_slug() {
1849 let _lock = crate::test_support::lock_test_env();
1850 let ws = tempfile::TempDir::new().unwrap();
1851 let fleet = sample_fleet();
1852 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1853
1854 let mut other = FleetFile::new("DeepSeek Flash!".to_string(), None).unwrap();
1855 other.members = fleet.members.clone();
1856 let err = save_fleet(&other, FleetScope::Workspace, ws.path()).unwrap_err();
1857 assert!(err.to_string().contains("already exists"), "{err}");
1858 }
1859
1860 #[test]
1861 fn save_refuses_to_overwrite_a_legacy_fleet_file_of_the_same_slug() {
1862 let _lock = crate::test_support::lock_test_env();
1863 let ws = tempfile::TempDir::new().unwrap();
1864 let dir = ws.path().join(".codewhale/fleets");
1865 std::fs::create_dir_all(&dir).unwrap();
1866 let legacy_path = dir.join("default.toml");
1867 let legacy = "[roles.builder]\nmodel = \"deepseek-v4-flash\"\n";
1868 std::fs::write(&legacy_path, legacy).unwrap();
1869
1870 let fleet = FleetFile::new("Default".to_string(), None).unwrap();
1871 let err = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap_err();
1872 assert!(err.to_string().contains("left unchanged"), "{err}");
1873 assert_eq!(std::fs::read_to_string(&legacy_path).unwrap(), legacy);
1874
1875 // A saved v2 Fleet of the same name is still updated in place.
1876 std::fs::remove_file(&legacy_path).unwrap();
1877 save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("first save");
1878 save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("re-save");
1879 }
1880
1881 #[test]
1882 fn save_leaves_an_unreadable_fleet_file_of_the_same_slug_unchanged() {
1883 let _lock = crate::test_support::lock_test_env();
1884 let ws = tempfile::TempDir::new().unwrap();
1885 let dir = ws.path().join(".codewhale/fleets");
1886 std::fs::create_dir_all(&dir).unwrap();
1887 let path = dir.join("default.toml");
1888 let fleet = FleetFile::new("Default".to_string(), None).unwrap();
1889
1890 for existing in [
1891 // Written by a newer build.
1892 "schema = \"fleet\"\nschema_revision = 3\nname = \"Default\"\n",
1893 // The schema declaration other readers normalize.
1894 "schema = \"Fleet\"\nschema_revision = 2\nname = \"Default\"\n",
1895 // A v2 Fleet the user is mid-edit on.
1896 "schema = \"fleet\"\nschema_revision = 2\nname = \"Default\"\nmembers = [\n",
1897 ] {
1898 std::fs::write(&path, existing).unwrap();
1899 let err = save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap_err();
1900 let message = err.to_string();
1901 assert!(message.contains("left unchanged"), "{message}");
1902 assert!(!message.contains("migrate"), "{message}");
1903 assert_eq!(std::fs::read_to_string(&path).unwrap(), existing);
1904 }
1905 let declared = "schema = \"fleet\"\nschema_revision = 3\nname = \"Default\"\n";
1906 std::fs::write(&path, declared).unwrap();
1907 let message = save_fleet(&fleet, FleetScope::Workspace, ws.path())
1908 .unwrap_err()
1909 .to_string();
1910 assert!(message.contains("cannot read"), "{message}");
1911
1912 // An empty placeholder is not a Fleet worth keeping.
1913 std::fs::write(&path, "").unwrap();
1914 save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("replace empty file");
1915 assert!(
1916 load_fleet_at(&path).is_ok(),
1917 "the saved Fleet must read back"
1918 );
1919 }
1920
1921 #[test]
1922 fn migration_preserves_pins_and_names_shadowing() {
1923 let _lock = crate::test_support::lock_test_env();
1924 let ws = tempfile::TempDir::new().unwrap();
1925
1926 // A workspace legacy profile file with a pin.
1927 let agents_dir = ws.path().join(".codewhale/agents");
1928 std::fs::create_dir_all(&agents_dir).unwrap();
1929 std::fs::write(
1930 agents_dir.join("scout.toml"),
1931 r#"id = "scout"
1932 display_name = "Scout One"
1933 role_hint = "scout"
1934 model = "deepseek-v4-flash"
1935 provider = "deepseek"
1936 "#,
1937 )
1938 .unwrap();
1939
1940 let receipt = migrate_legacy_roster(
1941 &codewhale_config::FleetConfigToml::default(),
1942 ws.path(),
1943 true,
1944 FleetScope::Workspace,
1945 )
1946 .expect("migration");
1947
1948 assert_eq!(receipt.fleet.name, "Default");
1949 let scout = receipt.fleet.member("scout").expect("scout member");
1950 assert_eq!(scout.display_name.as_deref(), Some("Scout One"));
1951 assert_eq!(scout.model.as_deref(), Some("deepseek-v4-flash"));
1952 assert_eq!(scout.provider.as_deref(), Some("deepseek"));
1953 assert!(receipt.saved_to.ends_with("fleets/default.toml"));
1954 // The legacy profile file itself is untouched.
1955 assert!(
1956 std::fs::read_to_string(agents_dir.join("scout.toml"))
1957 .unwrap()
1958 .contains("model = \"deepseek-v4-flash\"")
1959 );
1960 }
1961 }
1962
1962 lines RUST