use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::fleet_exact::{
EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError,
LEGACY_FLEET_SCHEMA_KIND, declared_schema_kind,
};
use crate::fleet_snapshot::QualifiedFleetId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetSearchRoot {
pub origin: String,
pub root: PathBuf,
}
impl FleetSearchRoot {
pub fn new(origin: impl Into<String>, root: impl Into<PathBuf>) -> Self {
Self {
origin: origin.into(),
root: root.into(),
}
}
}
fn split_qualified_fleet_name(name: &str) -> (Option<&str>, &str) {
let trimmed = name.trim();
match trimmed.split_once('/') {
Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
(Some(origin.trim()), bare.trim())
}
_ => (None, trimmed),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NamedFleet {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub roles: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum NamedFleetError {
#[error("fleet file not found: {0}")]
NotFound(String),
#[error("failed to read fleet file {path}: {message}")]
Io { path: String, message: String },
#[error("failed to parse fleet file {path}: {message}")]
Parse { path: String, message: String },
#[error("fleet `{fleet}` is missing required role `{role}`")]
MissingRole { fleet: String, role: String },
#[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
NameMismatch { declared: String, expected: String },
#[error(
"fleet `{name}` is defined in more than one place ({}); an exact fleet must not be \
resolved by shadowing. Name one explicitly as `origin/{name}`.",
origins.join(", ")
)]
AmbiguousFleet { name: String, origins: Vec<String> },
#[error("exact fleet `{fleet}`: {source}")]
Exact {
fleet: String,
#[source]
source: ExactFleetError,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum FleetSchema {
Legacy(NamedFleet),
Exact(ExactFleet),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetDocument {
schema: FleetSchema,
source: Option<PathBuf>,
source_hash: String,
}
impl FleetDocument {
pub fn parse(text: &str) -> Result<Self, NamedFleetError> {
let schema = match declared_schema_kind(text).as_deref() {
Some(EXACT_FLEET_SCHEMA_KIND) => {
let exact = ExactFleet::parse(text).map_err(|source| NamedFleetError::Exact {
fleet: "<memory>".to_string(),
source,
})?;
FleetSchema::Exact(exact)
}
Some(other) => {
return Err(NamedFleetError::Parse {
path: "<memory>".into(),
message: format!("unknown fleet schema `{other}`; expected `exact`"),
});
}
None => FleetSchema::Legacy(parse_named_fleet(text)?),
};
Ok(Self {
schema,
source: None,
source_hash: content_hash(text),
})
}
pub fn load(path: &Path, expect_name: Option<&str>) -> Result<Self, NamedFleetError> {
let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
let mut document = Self::parse(&text).map_err(|e| match e {
NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
path: path.display().to_string(),
message,
},
NamedFleetError::Exact { source, .. } => NamedFleetError::Exact {
fleet: path.display().to_string(),
source,
},
other => other,
})?;
if let Some(expected) = expect_name
&& document.name() != expected
{
return Err(NamedFleetError::NameMismatch {
declared: document.name().to_string(),
expected: expected.to_string(),
});
}
document.source = Some(path.to_path_buf());
Ok(document)
}
pub fn load_by_name(
name: &str,
search_roots: &[FleetSearchRoot],
) -> Result<(Self, QualifiedFleetId), NamedFleetError> {
let (requested_origin, bare_name) = split_qualified_fleet_name(name);
let file_name = format!("{bare_name}.toml");
let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
for root in search_roots {
if let Some(origin) = requested_origin
&& !root.origin.eq_ignore_ascii_case(origin)
{
continue;
}
let path = root.root.join("fleets").join(&file_name);
if path.is_file() {
candidates.push((root, path));
}
}
let Some((first_root, first_path)) = candidates.first() else {
return Err(NamedFleetError::NotFound(name.to_string()));
};
if candidates.len() > 1 {
let mut any_exact = false;
for (_, path) in &candidates {
let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
if declared_schema_kind(&text).is_some() {
any_exact = true;
break;
}
}
if any_exact {
return Err(NamedFleetError::AmbiguousFleet {
name: bare_name.to_string(),
origins: candidates
.iter()
.map(|(root, path)| {
format!("{}/{bare_name} ({})", root.origin, path.display())
})
.collect(),
});
}
}
let document = Self::load(first_path, Some(bare_name))?;
Ok((
document,
QualifiedFleetId {
name: bare_name.to_string(),
origin: first_root.origin.clone(),
},
))
}
#[must_use]
pub fn name(&self) -> &str {
match &self.schema {
FleetSchema::Legacy(fleet) => &fleet.name,
FleetSchema::Exact(fleet) => &fleet.name,
}
}
#[must_use]
pub fn description(&self) -> Option<&str> {
match &self.schema {
FleetSchema::Legacy(fleet) => fleet.description.as_deref(),
FleetSchema::Exact(fleet) => fleet.description.as_deref(),
}
}
#[must_use]
pub fn schema(&self) -> &FleetSchema {
&self.schema
}
#[must_use]
pub const fn is_legacy(&self) -> bool {
matches!(self.schema, FleetSchema::Legacy(_))
}
#[must_use]
pub fn legacy(&self) -> Option<&NamedFleet> {
match &self.schema {
FleetSchema::Legacy(fleet) => Some(fleet),
FleetSchema::Exact(_) => None,
}
}
#[must_use]
pub fn exact(&self) -> Option<&ExactFleet> {
match &self.schema {
FleetSchema::Exact(fleet) => Some(fleet),
FleetSchema::Legacy(_) => None,
}
}
#[must_use]
pub fn schema_kind(&self) -> &'static str {
match self.schema {
FleetSchema::Legacy(_) => LEGACY_FLEET_SCHEMA_KIND,
FleetSchema::Exact(_) => EXACT_FLEET_SCHEMA_KIND,
}
}
#[must_use]
pub fn schema_revision(&self) -> u32 {
match &self.schema {
FleetSchema::Legacy(_) => 0,
FleetSchema::Exact(fleet) => fleet.schema_revision,
}
}
#[must_use]
pub fn source_hash(&self) -> &str {
&self.source_hash
}
#[must_use]
pub fn source_path(&self) -> Option<&Path> {
self.source.as_deref()
}
#[cfg(test)]
#[must_use]
pub(crate) fn from_exact_for_tests(exact: ExactFleet) -> Self {
Self {
schema: FleetSchema::Exact(exact),
source: None,
source_hash: content_hash("<constructed>"),
}
}
}
#[must_use]
pub const fn exact_schema_revision() -> u32 {
EXACT_FLEET_SCHEMA_REVISION
}
pub(crate) fn content_hash(text: &str) -> String {
sha256_label(text.as_bytes())
}
pub(crate) fn sha256_label(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(7 + digest.len() * 2);
out.push_str("sha256:");
for byte in digest.iter() {
let _ = write!(&mut out, "{byte:02x}");
}
out
}
pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[
"scout",
"implementer",
"reviewer",
"verifier",
"release_lead",
];
pub fn parse_named_fleet(toml_text: &str) -> Result<NamedFleet, NamedFleetError> {
let trimmed = toml_text.trim();
if trimmed.starts_with('{') {
return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse {
path: "<memory>".into(),
message: e.to_string(),
});
}
parse_fleet_toml_minimal(trimmed)
}
fn strip_toml_comment(line: &str) -> &str {
let mut quote = None;
let mut escaped = false;
for (index, character) in line.char_indices() {
match quote {
Some('"') => {
if escaped {
escaped = false;
} else {
match character {
'\\' => escaped = true,
'"' => quote = None,
_ => {}
}
}
}
Some('\'') => {
if character == '\'' {
quote = None;
}
}
Some(_) => unreachable!("only TOML string delimiters are tracked"),
None => match character {
'"' | '\'' => quote = Some(character),
'#' => return &line[..index],
_ => {}
},
}
}
line
}
fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
let mut name = None;
let mut description = None;
let mut roles = BTreeMap::new();
let mut section = "";
for raw in text.lines() {
let line = strip_toml_comment(raw).trim();
if line.is_empty() {
continue;
}
if line.starts_with('[') && line.ends_with(']') {
section = &line[1..line.len() - 1];
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').to_string();
match section {
"" => match key {
"name" => name = Some(value),
"description" => description = Some(value),
_ => {}
},
"roles" => {
roles.insert(key.to_string(), value);
}
_ => {}
}
}
let name = name.ok_or_else(|| NamedFleetError::Parse {
path: "<memory>".into(),
message: "missing name".into(),
})?;
Ok(NamedFleet {
name,
description,
roles,
})
}
pub fn load_named_fleet(
name: &str,
search_roots: &[PathBuf],
) -> Result<NamedFleet, NamedFleetError> {
let file_name = format!("{name}.toml");
for root in search_roots {
let path = root.join("fleets").join(&file_name);
if path.is_file() {
return load_named_fleet_file(&path, Some(name));
}
}
Err(NamedFleetError::NotFound(name.to_string()))
}
pub fn load_named_fleet_file(
path: &Path,
expect_name: Option<&str>,
) -> Result<NamedFleet, NamedFleetError> {
let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
path: path.display().to_string(),
message: e.to_string(),
})?;
let fleet = parse_named_fleet(&text).map_err(|e| match e {
NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
path: path.display().to_string(),
message,
},
other => other,
})?;
if let Some(expected) = expect_name
&& fleet.name != expected
{
return Err(NamedFleetError::NameMismatch {
declared: fleet.name,
expected: expected.to_string(),
});
}
Ok(fleet)
}
impl NamedFleet {
pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
let key = role.trim().to_ascii_lowercase();
self.roles
.get(&key)
.or_else(|| {
self.roles
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(role))
.map(|(_, v)| v)
})
.map(String::as_str)
.ok_or_else(|| NamedFleetError::MissingRole {
fleet: self.name.clone(),
role: role.to_string(),
})
}
pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
for role in STOPSHIP_REQUIRED_ROLES {
self.resolve(role)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const STOPSHIP_TOML: &str = r#"
name = "stopship"
description = "Stopship dogfood fleet"
[roles]
scout = "scout"
implementer = "builder"
reviewer = "reviewer"
verifier = "verifier"
release_lead = "manager"
"#;
#[test]
fn stopship_fleet_resolves_all_five_roles() {
let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
assert_eq!(fleet.name, "stopship");
fleet.validate_stopship_roles().expect("all roles");
assert_eq!(fleet.resolve("scout").unwrap(), "scout");
assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
}
#[test]
fn unknown_role_fails_clearly() {
let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
let err = fleet.resolve("wizard").unwrap_err();
assert!(matches!(err, NamedFleetError::MissingRole { .. }));
}
#[test]
fn quoted_hashes_are_not_treated_as_comments() {
let fleet = parse_named_fleet(
r#"
name = "issue-references"
description = "Tracks #4178 dogfood" # real comment
[roles]
scout = "scout#stable"
"#,
)
.expect("parse");
assert_eq!(fleet.description.as_deref(), Some("Tracks #4178 dogfood"));
assert_eq!(fleet.resolve("scout").unwrap(), "scout#stable");
}
#[test]
fn comment_stripping_tracks_toml_quotes_and_escapes() {
assert_eq!(
strip_toml_comment(r##"description = "say \"#still-value\"" # comment"##).trim_end(),
r##"description = "say \"#still-value\"""##
);
assert_eq!(
strip_toml_comment("description = 'tracks #4178' # comment").trim_end(),
"description = 'tracks #4178'"
);
assert_eq!(
strip_toml_comment(r#"name = "stopship" # comment"#).trim_end(),
r#"name = "stopship""#
);
}
#[test]
fn legacy_fleet_files_still_deserialize_and_resolve_through_the_document_api() {
let document = FleetDocument::parse(STOPSHIP_TOML).expect("legacy parse");
assert!(document.is_legacy());
assert_eq!(document.schema_kind(), "legacy");
assert_eq!(document.schema_revision(), 0);
assert!(document.exact().is_none());
let legacy = document.legacy().expect("legacy body");
legacy.validate_stopship_roles().expect("all roles");
assert_eq!(legacy.resolve("implementer").unwrap(), "builder");
assert_eq!(document.name(), "stopship");
assert!(document.source_hash().starts_with("sha256:"));
}
#[test]
fn exact_fleet_files_are_selected_by_an_explicit_schema_key() {
let document = FleetDocument::parse(
r#"
name = "glm-pair"
schema = "exact"
[[members]]
id = "implementer"
provider = "zai"
model = "glm-5"
reasoning = "auto"
[[members]]
id = "router"
kind = "router"
provider = "zai"
model = "glm-5-turbo"
"#,
)
.expect("exact parse");
assert!(!document.is_legacy());
assert_eq!(document.schema_kind(), "exact");
assert_eq!(document.schema_revision(), exact_schema_revision());
assert!(document.legacy().is_none());
let exact = document.exact().expect("exact body");
assert!(exact.has_auto_member());
assert!(exact.legacy_inline_router().is_some());
assert!(exact.router_ref().is_some());
}
#[test]
fn an_unknown_schema_key_fails_instead_of_falling_back_to_legacy() {
let err = FleetDocument::parse("name = \"f\"\nschema = \"experimental\"\n")
.expect_err("unknown schema must not silently parse as legacy");
assert!(matches!(err, NamedFleetError::Parse { .. }), "{err:?}");
}
#[test]
fn document_hash_follows_the_file_bytes() {
let a = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
let b = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
let c = FleetDocument::parse(&STOPSHIP_TOML.replace("builder", "implementer_profile"))
.expect("parse");
assert_eq!(a.source_hash(), b.source_hash());
assert_ne!(a.source_hash(), c.source_hash());
}
#[test]
fn loads_workspace_fleet_file() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
fleet.validate_stopship_roles().unwrap();
}
}