use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use super::roster::FleetRoster;
pub const FLEET_SCHEMA_KIND: &str = "fleet";
pub const FLEET_SCHEMA_REVISION: u32 = 2;
pub const FLEET_DIR: &str = "fleets";
pub const SELECTED_FILE: &str = "selected";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FleetScope {
Personal,
Workspace,
}
impl FleetScope {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Personal => "user",
Self::Workspace => "folder",
}
}
#[must_use]
pub const fn long_label(self) -> &'static str {
match self {
Self::Personal => "user-global",
Self::Workspace => "folder (this workspace)",
}
}
#[must_use]
pub const fn toggled(self) -> Self {
match self {
Self::Personal => Self::Workspace,
Self::Workspace => Self::Personal,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FleetOperator {
pub provider: String,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberCapability {
Vision,
}
impl MemberCapability {
pub const VOCABULARY: [&'static str; 1] = ["vision"];
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"vision" | "image" | "image-input" => Some(Self::Vision),
_ => None,
}
}
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Self::Vision => "vision",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FleetMember {
pub id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FleetFile {
pub schema: String,
pub schema_revision: u32,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator: Option<FleetOperator>,
#[serde(default)]
pub members: Vec<FleetMember>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum FleetStoreError {
#[error("invalid fleet: {0}")]
Invalid(String),
#[error(
"fleet `{0}` is defined in both {1} and {2}; name one explicitly as {1}/{0} or {2}/{0}"
)]
Ambiguous(String, String, String),
#[error("fleet file not found: {0}")]
NotFound(String),
#[error("failed to read {path}: {message}")]
Io { path: String, message: String },
#[error("failed to parse {path}: {message}")]
Parse { path: String, message: String },
#[error("a fleet named `{name}` already exists at {path}; rename it or choose another name")]
NameTaken { name: String, path: String },
}
impl FleetFile {
pub fn new(name: String, description: Option<String>) -> Result<Self, FleetStoreError> {
let fleet = Self {
schema: FLEET_SCHEMA_KIND.to_string(),
schema_revision: FLEET_SCHEMA_REVISION,
name,
description,
operator: None,
members: Vec::new(),
};
fleet.validate()?;
Ok(fleet)
}
pub fn validate(&self) -> Result<(), FleetStoreError> {
if self.schema != FLEET_SCHEMA_KIND {
return Err(FleetStoreError::Invalid(format!(
"unknown schema `{}`; expected `{FLEET_SCHEMA_KIND}`",
self.schema
)));
}
if self.schema_revision != FLEET_SCHEMA_REVISION {
return Err(FleetStoreError::Invalid(format!(
"unsupported schema revision {}; this build reads revision {FLEET_SCHEMA_REVISION}",
self.schema_revision
)));
}
let name = self.name.trim();
if name.is_empty() {
return Err(FleetStoreError::Invalid(
"fleet name must not be empty".to_string(),
));
}
let mut seen: BTreeMap<&str, ()> = BTreeMap::new();
for member in &self.members {
if member.id.trim().is_empty() {
return Err(FleetStoreError::Invalid(
"member id must not be empty".to_string(),
));
}
if seen.insert(member.id.as_str(), ()).is_some() {
return Err(FleetStoreError::Invalid(format!(
"duplicate member id `{}`",
member.id
)));
}
match (&member.provider, &member.model) {
(Some(_), None) | (None, Some(_)) => {
return Err(FleetStoreError::Invalid(format!(
"member `{}` must pin both provider and model, or neither (inherit); a lone {} is rejected",
member.id,
if member.provider.is_some() {
"provider"
} else {
"model"
}
)));
}
_ => {}
}
for requirement in &member.requires {
if MemberCapability::parse(requirement).is_none() {
return Err(FleetStoreError::Invalid(format!(
"member `{}` requires unknown capability `{requirement}`; valid values: {}",
member.id,
MemberCapability::VOCABULARY.join(", ")
)));
}
}
}
Ok(())
}
pub fn render_toml(&self) -> Result<String, FleetStoreError> {
self.validate()?;
let rendered = toml::to_string_pretty(self)
.map_err(|e| FleetStoreError::Invalid(format!("failed to serialize fleet: {e}")))?;
Ok(rendered)
}
pub fn parse(text: &str) -> Result<Self, FleetStoreError> {
let fleet: Self = toml::from_str(text)
.map_err(|e| FleetStoreError::Invalid(format!("invalid fleet TOML: {e}")))?;
fleet.validate()?;
Ok(fleet)
}
#[must_use]
pub fn file_slug(&self) -> String {
slugify(&self.name)
}
#[must_use]
pub fn member(&self, id: &str) -> Option<&FleetMember> {
self.members.iter().find(|m| m.id == id)
}
#[must_use]
pub fn has_scout(&self) -> bool {
self.member("scout").is_some()
}
}
fn slugify(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
for ch in name.trim().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !slug.ends_with('-') {
slug.push('-');
}
}
while slug.ends_with('-') {
slug.pop();
}
if slug.is_empty() {
"fleet".to_string()
} else {
slug
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetEntry {
pub name: String,
pub scope: FleetScope,
pub path: PathBuf,
pub parse_error: Option<String>,
pub legacy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedFleet {
pub name: String,
pub scope: FleetScope,
pub path: PathBuf,
}
fn personal_fleets_dir() -> Result<PathBuf, FleetStoreError> {
codewhale_config::codewhale_home()
.map(|home| home.join(FLEET_DIR))
.map_err(|e| FleetStoreError::Io {
path: "$CODEWHALE_HOME/fleets".to_string(),
message: e.to_string(),
})
}
fn workspace_fleets_dir(workspace: &Path) -> PathBuf {
workspace.join(".codewhale").join(FLEET_DIR)
}
fn ensure_fleets_dir(scope: FleetScope, workspace: &Path) -> Result<PathBuf, FleetStoreError> {
let dir = match scope {
FleetScope::Personal => personal_fleets_dir()?,
FleetScope::Workspace => workspace_fleets_dir(workspace),
};
fs::create_dir_all(&dir).map_err(|e| FleetStoreError::Io {
path: dir.display().to_string(),
message: e.to_string(),
})?;
Ok(dir)
}
pub fn list_fleets(workspace: &Path) -> Vec<FleetEntry> {
let mut entries = Vec::new();
if let Ok(dir) = personal_fleets_dir() {
collect_entries(&dir, FleetScope::Personal, &mut entries);
}
collect_entries(
&workspace_fleets_dir(workspace),
FleetScope::Workspace,
&mut entries,
);
entries.sort_by(|a, b| {
a.scope
.label()
.cmp(b.scope.label())
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
entries
}
fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
let Ok(read) = fs::read_dir(dir) else {
return;
};
let mut files: Vec<PathBuf> = read
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
.collect();
files.sort();
for path in files {
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let text = fs::read_to_string(&path).ok();
let parse_error = text
.as_deref()
.and_then(|text| FleetFile::parse(text).err())
.map(|e| e.to_string());
let legacy = parse_error.as_deref().is_some_and(|err| {
err.contains("unknown schema") || err.contains("invalid fleet TOML")
});
let name = text
.as_deref()
.and_then(|text| toml::from_str::<toml::Value>(text).ok())
.and_then(|value| {
value
.get("name")
.and_then(|n| n.as_str())
.map(str::trim)
.filter(|n| !n.is_empty())
.map(str::to_string)
})
.unwrap_or(stem);
out.push(FleetEntry {
name,
scope,
path,
parse_error,
legacy,
});
}
}
#[allow(dead_code)]
pub fn load_fleet(
name: &str,
workspace: &Path,
) -> Result<(FleetFile, FleetScope, PathBuf), FleetStoreError> {
let name = name.trim();
if name.is_empty() {
return Err(FleetStoreError::NotFound("<empty name>".to_string()));
}
let mut found: Vec<(FleetScope, PathBuf)> = Vec::new();
if let Ok(dir) = personal_fleets_dir() {
let path = dir.join(format!("{}.toml", slugify(name)));
if path.is_file() {
found.push((FleetScope::Personal, path));
}
}
let ws_path = workspace_fleets_dir(workspace).join(format!("{}.toml", slugify(name)));
if ws_path.is_file() {
found.push((FleetScope::Workspace, ws_path));
}
if found.len() > 1 {
return Err(FleetStoreError::Ambiguous(
name.to_string(),
FleetScope::Personal.label().to_string(),
FleetScope::Workspace.label().to_string(),
));
}
let Some((scope, path)) = found.pop() else {
return Err(FleetStoreError::NotFound(name.to_string()));
};
let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
path: path.display().to_string(),
message: e.to_string(),
})?;
Ok((fleet, scope, path))
}
pub fn load_fleet_in_scope(
name: &str,
scope: FleetScope,
workspace: &Path,
) -> Result<(FleetFile, PathBuf), FleetStoreError> {
let dir = match scope {
FleetScope::Personal => personal_fleets_dir()?,
FleetScope::Workspace => workspace_fleets_dir(workspace),
};
let path = dir.join(format!("{}.toml", slugify(name)));
if !path.is_file() {
return Err(FleetStoreError::NotFound(format!(
"{} ({})",
name,
scope.label()
)));
}
let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
path: path.display().to_string(),
message: e.to_string(),
})?;
Ok((fleet, path))
}
#[allow(dead_code)]
pub fn load_fleet_at(path: &Path) -> Result<(FleetFile, FleetScope), FleetStoreError> {
let text = fs::read_to_string(path).map_err(|e| FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
path: path.display().to_string(),
message: e.to_string(),
})?;
let scope = if path.starts_with(personal_fleets_dir().unwrap_or_default()) {
FleetScope::Personal
} else {
FleetScope::Workspace
};
Ok((fleet, scope))
}
pub fn save_fleet(
fleet: &FleetFile,
scope: FleetScope,
workspace: &Path,
) -> Result<PathBuf, FleetStoreError> {
fleet.validate()?;
let dir = ensure_fleets_dir(scope, workspace)?;
let path = dir.join(format!("{}.toml", fleet.file_slug()));
if path.is_file()
&& let Ok(text) = fs::read_to_string(&path)
&& let Ok(existing) = FleetFile::parse(&text)
&& existing.name != fleet.name
{
return Err(FleetStoreError::NameTaken {
name: fleet.name.clone(),
path: path.display().to_string(),
});
}
let rendered = fleet.render_toml()?;
atomic_write(&path, rendered.as_bytes())?;
Ok(path)
}
pub fn delete_fleet(
name: &str,
scope: FleetScope,
workspace: &Path,
) -> Result<PathBuf, FleetStoreError> {
let dir = match scope {
FleetScope::Personal => personal_fleets_dir()?,
FleetScope::Workspace => workspace_fleets_dir(workspace),
};
let path = dir.join(format!("{}.toml", slugify(name)));
if !path.is_file() {
return Err(FleetStoreError::NotFound(name.to_string()));
}
fs::remove_file(&path).map_err(|e| FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
clear_selection_if_matching(scope, workspace, name);
Ok(path)
}
pub fn selected_fleet(workspace: &Path) -> Option<SelectedFleet> {
let ws_dir = workspace_fleets_dir(workspace);
if let Some(name) = read_selection(&ws_dir) {
let ws_path = ws_dir.join(format!("{}.toml", slugify(&name)));
if ws_path.is_file() {
return Some(SelectedFleet {
name,
scope: FleetScope::Workspace,
path: ws_path,
});
}
if let Ok(dir) = personal_fleets_dir() {
let personal_path = dir.join(format!("{}.toml", slugify(&name)));
if personal_path.is_file() {
return Some(SelectedFleet {
name,
scope: FleetScope::Personal,
path: personal_path,
});
}
}
}
if let Ok(dir) = personal_fleets_dir()
&& let Some(name) = read_selection(&dir)
{
let path = dir.join(format!("{}.toml", slugify(&name)));
if path.is_file() {
return Some(SelectedFleet {
name,
scope: FleetScope::Personal,
path,
});
}
}
None
}
fn read_selection(dir: &Path) -> Option<String> {
let text = fs::read_to_string(dir.join(SELECTED_FILE)).ok()?;
let name = text.trim();
if name.is_empty() {
None
} else {
Some(name.to_string())
}
}
pub fn set_selected(
name: &str,
scope: FleetScope,
workspace: &Path,
) -> Result<PathBuf, FleetStoreError> {
let dir = ensure_fleets_dir(scope, workspace)?;
let name = name.trim();
let exists_in_scope = |target: FleetScope| {
let target_dir = match target {
FleetScope::Personal => personal_fleets_dir().ok(),
FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
};
target_dir
.map(|d| d.join(format!("{}.toml", slugify(name))).is_file())
.unwrap_or(false)
};
let exists = exists_in_scope(scope) || exists_in_scope(FleetScope::Personal);
if !exists {
return Err(FleetStoreError::NotFound(format!(
"{} ({})",
name,
scope.label()
)));
}
let selected = dir.join(SELECTED_FILE);
atomic_write(&selected, name.as_bytes())?;
Ok(selected)
}
fn clear_selection_if_matching(scope: FleetScope, workspace: &Path, name: &str) {
let dir = match scope {
FleetScope::Personal => personal_fleets_dir().ok(),
FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
};
let Some(dir) = dir else { return };
let selected = dir.join(SELECTED_FILE);
if read_selection(&dir).as_deref() == Some(name.trim()) {
let _ = fs::remove_file(selected);
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), FleetStoreError> {
let tmp = path.with_extension("tmp");
fs::write(&tmp, bytes).map_err(|e| FleetStoreError::Io {
path: tmp.display().to_string(),
message: e.to_string(),
})?;
if let Err(e) = fs::rename(&tmp, path) {
let _ = fs::remove_file(&tmp);
return Err(FleetStoreError::Io {
path: path.display().to_string(),
message: e.to_string(),
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationRow {
pub id: String,
pub pin: Option<(String, String)>,
pub winner: String,
pub identical_shadow: Option<String>,
pub conflicting_shadow: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationReceipt {
pub fleet: FleetFile,
pub rows: Vec<MigrationRow>,
pub saved_to: PathBuf,
}
pub fn migrate_legacy_roster(
fleet_config: &codewhale_config::FleetConfigToml,
workspace: &Path,
save: bool,
save_scope: FleetScope,
) -> Result<MigrationReceipt, FleetStoreError> {
let roster = FleetRoster::load(fleet_config, workspace);
let mut fleet = FleetFile::new(
"Default".to_string(),
Some("Migrated from the legacy per-role profile configuration.".to_string()),
)?;
let mut rows = Vec::new();
for member in roster.members() {
let profile = &member.profile;
let (model, provider) = match (&profile.model, &profile.provider) {
(Some(model), Some(provider)) => (Some(model.clone()), Some(provider.clone())),
_ => (None, None),
};
let requires: Vec<String> = Vec::new();
let row = MigrationRow {
id: member.id.clone(),
pin: model
.as_ref()
.map(|m| (m.clone(), provider.clone().unwrap_or_default())),
winner: member.origin.to_string(),
identical_shadow: None,
conflicting_shadow: None,
};
let shadows: Vec<String> = roster
.shadowed()
.iter()
.filter(|s| s.id == member.id)
.map(|s| {
format!(
"{} copy at {} ignored in favor of {}",
s.shadowed_origin,
s.shadowed_source.display(),
s.winner_origin
)
})
.collect();
let mut row = row;
if let Some(first) = shadows.first() {
if shadows.len() == 1 && first.contains("built-in") {
row.identical_shadow = Some(first.clone());
} else {
row.conflicting_shadow = Some(shadows.join("; "));
}
}
rows.push(row);
fleet.members.push(FleetMember {
id: member.id.clone(),
role: profile.role.name.clone(),
model,
provider,
reasoning: profile.reasoning_effort.clone(),
instructions: profile.role.instructions.clone(),
requires,
});
}
fleet.validate()?;
let saved_to = if save {
save_fleet(&fleet, save_scope, workspace)?
} else {
match save_scope {
FleetScope::Personal => {
personal_fleets_dir()?.join(format!("{}.toml", fleet.file_slug()))
}
FleetScope::Workspace => {
workspace_fleets_dir(workspace).join(format!("{}.toml", fleet.file_slug()))
}
}
};
Ok(MigrationReceipt {
fleet,
rows,
saved_to,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::OnceLock;
fn sealed_home() -> &'static Path {
static HOME: OnceLock<PathBuf> = OnceLock::new();
HOME.get_or_init(|| {
let dir = tempfile::TempDir::new()
.expect("temp dir for sealed home")
.keep();
std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
dir
})
}
struct EnvGuard {
prev: Option<std::ffi::OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.prev {
Some(v) => std::env::set_var("CODEWHALE_HOME", v),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
}
fn set_sealed_home() -> EnvGuard {
let prev = std::env::var_os("CODEWHALE_HOME");
unsafe {
std::env::set_var("CODEWHALE_HOME", sealed_home());
}
EnvGuard { prev }
}
fn sample_fleet() -> FleetFile {
FleetFile::new("DeepSeek Flash".to_string(), None)
.expect("valid fleet")
.with_operator(FleetOperator {
provider: "deepseek".to_string(),
model: "deepseek-v4-flash".to_string(),
reasoning: Some("low".to_string()),
})
.with_member(FleetMember {
id: "scout".to_string(),
role: "scout".to_string(),
provider: None,
model: None,
reasoning: None,
instructions: None,
requires: Vec::new(),
})
.with_member(FleetMember {
id: "builder".to_string(),
role: "builder".to_string(),
provider: Some("deepseek".to_string()),
model: Some("deepseek-v4-pro".to_string()),
reasoning: Some("high".to_string()),
instructions: Some("Implement exactly the task slice.".to_string()),
requires: vec!["vision".to_string()],
})
}
trait FleetBuilder {
fn with_operator(self, operator: FleetOperator) -> Self;
fn with_member(self, member: FleetMember) -> Self;
}
impl FleetBuilder for FleetFile {
fn with_operator(mut self, operator: FleetOperator) -> Self {
self.operator = Some(operator);
self
}
fn with_member(mut self, member: FleetMember) -> Self {
self.members.push(member);
self
}
}
#[test]
fn validation_rejects_bad_documents_with_specific_errors() {
let _lock = crate::test_support::lock_test_env();
let err = FleetFile::new(" ".to_string(), None).unwrap_err();
assert!(err.to_string().contains("name must not be empty"), "{err}");
let mut fleet = sample_fleet();
fleet.members.push(fleet.members[0].clone());
let err = fleet.validate().unwrap_err();
assert!(
err.to_string().contains("duplicate member id `scout`"),
"{err}"
);
let mut fleet = sample_fleet();
fleet.members[0].provider = Some("deepseek".to_string());
let err = fleet.validate().unwrap_err();
assert!(
err.to_string().contains("must pin both provider and model"),
"{err}"
);
let mut fleet = sample_fleet();
fleet.members[0].model = Some("deepseek-v4-pro".to_string());
let err = fleet.validate().unwrap_err();
assert!(
err.to_string().contains("must pin both provider and model"),
"{err}"
);
let mut fleet = sample_fleet();
fleet.members[0].requires = vec!["telepathy".to_string()];
let err = fleet.validate().unwrap_err();
assert!(
err.to_string().contains("unknown capability `telepathy`"),
"{err}"
);
assert!(err.to_string().contains("vision"), "{err}");
}
#[test]
fn render_parse_round_trip_preserves_every_field() {
let fleet = sample_fleet();
let text = fleet.render_toml().expect("render");
let parsed = FleetFile::parse(&text).expect("parse");
assert_eq!(parsed, fleet);
assert!(text.contains("schema = \"fleet\""));
assert!(text.contains("schema_revision = 2"));
assert!(text.contains("deepseek-v4-flash"));
}
#[test]
fn save_load_round_trips_in_workspace_scope() {
let _lock = crate::test_support::lock_test_env();
let ws = tempfile::TempDir::new().unwrap();
let fleet = sample_fleet();
let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
assert!(
path.ends_with(".codewhale/fleets/deepseek-flash.toml"),
"{path:?}"
);
let (loaded, scope, path) = load_fleet("DeepSeek Flash", ws.path()).expect("load");
assert_eq!(loaded, fleet);
assert_eq!(scope, FleetScope::Workspace);
assert_eq!(path, load_fleet("DeepSeek Flash", ws.path()).unwrap().2);
let _home = set_sealed_home();
save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal");
let err = load_fleet("DeepSeek Flash", ws.path()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("defined in both"), "{msg}");
assert!(msg.contains("user") && msg.contains("folder"), "{msg}");
}
#[test]
fn selection_is_scope_explicit_and_workspace_wins() {
let _lock = crate::test_support::lock_test_env();
let _home = set_sealed_home();
let ws = tempfile::TempDir::new().unwrap();
let fleet = sample_fleet();
assert!(selected_fleet(ws.path()).is_none());
save_fleet(&fleet, FleetScope::Personal, ws.path()).unwrap();
let selected_file =
set_selected("DeepSeek Flash", FleetScope::Personal, ws.path()).expect("select");
assert!(
selected_file.ends_with("fleets/selected"),
"{selected_file:?}"
);
let sel = selected_fleet(ws.path()).expect("selected");
assert_eq!(sel.scope, FleetScope::Personal);
assert_eq!(sel.name, "DeepSeek Flash");
let err = set_selected("No Such Fleet", FleetScope::Personal, ws.path()).unwrap_err();
assert!(err.to_string().contains("No Such Fleet"), "{err}");
save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
set_selected("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
let sel = selected_fleet(ws.path()).expect("selected");
assert_eq!(sel.scope, FleetScope::Workspace);
delete_fleet("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
let sel = selected_fleet(ws.path()).expect("personal default returns");
assert_eq!(sel.scope, FleetScope::Personal);
}
#[test]
fn list_marks_legacy_files_without_hiding_them() {
let _lock = crate::test_support::lock_test_env();
let _home = set_sealed_home();
let ws = tempfile::TempDir::new().unwrap();
save_fleet(&sample_fleet(), FleetScope::Personal, ws.path()).unwrap();
let legacy = r#"schema = "exact"
schema_revision = 1
name = "stopship"
members = []"#;
std::fs::write(sealed_home().join("fleets/stopship.toml"), legacy).unwrap();
let entries = list_fleets(ws.path());
assert_eq!(entries.len(), 2, "{entries:?}");
let stopship = entries
.iter()
.find(|e| e.name == "stopship")
.expect("legacy fleet listed");
assert!(stopship.legacy, "{stopship:?}");
assert!(stopship.parse_error.is_some(), "{stopship:?}");
let flash = entries.iter().find(|e| e.name == "DeepSeek Flash").unwrap();
assert!(!flash.legacy && flash.parse_error.is_none(), "{flash:?}");
}
#[test]
fn save_refuses_to_clobber_a_different_fleet_of_the_same_slug() {
let _lock = crate::test_support::lock_test_env();
let ws = tempfile::TempDir::new().unwrap();
let fleet = sample_fleet();
save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
let mut other = FleetFile::new("DeepSeek Flash!".to_string(), None).unwrap();
other.members = fleet.members.clone();
let err = save_fleet(&other, FleetScope::Workspace, ws.path()).unwrap_err();
assert!(err.to_string().contains("already exists"), "{err}");
}
#[test]
fn migration_preserves_pins_and_names_shadowing() {
let _lock = crate::test_support::lock_test_env();
let ws = tempfile::TempDir::new().unwrap();
let agents_dir = ws.path().join(".codewhale/agents");
std::fs::create_dir_all(&agents_dir).unwrap();
std::fs::write(
agents_dir.join("scout.toml"),
r#"id = "scout"
role_hint = "scout"
model = "deepseek-v4-flash"
provider = "deepseek"
"#,
)
.unwrap();
let receipt = migrate_legacy_roster(
&codewhale_config::FleetConfigToml::default(),
ws.path(),
true,
FleetScope::Workspace,
)
.expect("migration");
assert_eq!(receipt.fleet.name, "Default");
let scout = receipt.fleet.member("scout").expect("scout member");
assert_eq!(scout.model.as_deref(), Some("deepseek-v4-flash"));
assert_eq!(scout.provider.as_deref(), Some("deepseek"));
assert!(receipt.saved_to.ends_with("fleets/default.toml"));
assert!(
std::fs::read_to_string(agents_dir.join("scout.toml"))
.unwrap()
.contains("model = \"deepseek-v4-flash\"")
);
}
}