use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::Config;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Crew {
pub planner: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub navigator: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub triage: Option<String>,
#[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
pub loop_program: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budgets: Option<CrewBudgets>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrewPolicyConfig {
#[serde(default)]
pub clamp: crate::caveats::Caveats,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CrewBudgets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_files_touched: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_lines_changed: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub require_human_review_on: Vec<String>,
}
impl Crew {
pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
let check = |label: &str, name: &str| -> std::result::Result<(), String> {
let loadout = cfg.loadouts.get(name).ok_or_else(|| {
let known = if cfg.loadouts.is_empty() {
"none defined".to_string()
} else {
cfg.loadouts.keys().cloned().collect::<Vec<_>>().join(", ")
};
format!(
"crew {label} '{name}': no [loadouts] entry named '{name}' (known: {known})"
)
})?;
loadout
.validate(cfg)
.map_err(|e| format!("crew {label} '{name}': {e}"))
};
check("planner", &self.planner)?;
if let Some(nav) = &self.navigator {
check("navigator", nav)?;
}
if let Some(tri) = &self.triage {
check("triage", tri)?;
}
Ok(())
}
}
impl Config {
pub(super) fn merge_disk_crews(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_crews_from_dir(&dir.join("crews"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_crews_from_dir(&parent.join("crews"));
}
}
}
pub(super) fn merge_crews_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<Crew>(&t)) {
Ok(Ok(crew)) => {
self.crews.insert(stem.to_string(), crew);
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed crew file");
}
Err(_) => {}
}
}
}
}