huskies: merge 482_refactor_split_agent_definitions_from_project_toml_into_agents_toml

This commit is contained in:
dave
2026-04-04 21:20:36 +00:00
parent f63ed664eb
commit 470e7a5fd5
5 changed files with 455 additions and 354 deletions
+89 -9
View File
@@ -224,20 +224,47 @@ impl Default for ProjectConfig {
}
}
/// Config parsed from `.huskies/agents.toml` — agent definitions only.
#[derive(Debug, Deserialize)]
struct AgentsConfig {
#[serde(default)]
agent: Vec<AgentConfig>,
}
impl ProjectConfig {
/// Load from `.huskies/project.toml` relative to the given root.
/// Falls back to sensible defaults if the file doesn't exist.
/// Load from `.huskies/project.toml` relative to the given root,
/// then overlay agents from `.huskies/agents.toml` if present.
///
/// Supports both the new `[[agent]]` array format and the legacy
/// `[agent]` single-table format (with a deprecation warning).
/// Loading order:
/// 1. Project settings (watcher, default_qa, etc.) always come from `project.toml`.
/// 2. Agent definitions come from `agents.toml` when that file exists.
/// 3. Falls back to inline `[[agent]]` blocks in `project.toml` for backwards
/// compatibility with projects that haven't migrated yet.
/// 4. Falls back to a single default agent when neither file defines agents.
pub fn load(project_root: &Path) -> Result<Self, String> {
let config_path = project_root.join(".huskies/project.toml");
if !config_path.exists() {
return Ok(Self::default());
let mut config = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
Self::parse(&content)?
} else {
Self::default()
};
// agents.toml takes priority over inline [[agent]] in project.toml.
let agents_path = project_root.join(".huskies/agents.toml");
if agents_path.exists() {
let content = std::fs::read_to_string(&agents_path)
.map_err(|e| format!("Read agents.toml: {e}"))?;
let agents_cfg: AgentsConfig =
toml::from_str(&content).map_err(|e| format!("Parse agents.toml: {e}"))?;
if !agents_cfg.agent.is_empty() {
validate_agents(&agents_cfg.agent)?;
config.agent = agents_cfg.agent;
}
}
let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
Self::parse(&content)
Ok(config)
}
/// Parse config from a TOML string, supporting both new and legacy formats.
@@ -675,6 +702,59 @@ model = "sonnet"
assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
}
#[test]
fn agents_toml_overrides_project_toml_agents() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
fs::create_dir_all(&sk).unwrap();
// project.toml has inline agents
fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "from-project-toml"
model = "sonnet"
"#,
)
.unwrap();
// agents.toml overrides with different agents
fs::write(
sk.join("agents.toml"),
r#"
[[agent]]
name = "from-agents-toml"
model = "opus"
"#,
)
.unwrap();
let config = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config.agent.len(), 1);
assert_eq!(config.agent[0].name, "from-agents-toml");
assert_eq!(config.agent[0].model, Some("opus".to_string()));
}
#[test]
fn agents_toml_absent_falls_back_to_project_toml() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "inline-agent"
model = "sonnet"
"#,
)
.unwrap();
// No agents.toml — should use inline agents from project.toml
let config = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config.agent.len(), 1);
assert_eq!(config.agent[0].name, "inline-agent");
}
// ── WatcherConfig ──────────────────────────────────────────────────────
#[test]