use crate::providers;
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct Config {
pub project: ProjectConfig,
pub agents: AgentsConfig,
pub limits: LimitsConfig,
pub hooks: HooksConfig,
pub git: GitConfig,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
#[serde(default = "default_stories_dir")]
pub stories_dir: String,
#[serde(default = "default_story_pattern")]
pub story_pattern: String,
#[serde(default = "default_epics_dir")]
#[allow(dead_code)]
pub epics_dir: String,
#[serde(default = "default_decisions_dir")]
pub decisions_dir: String,
#[serde(default = "default_log_dir")]
pub log_dir: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct AgentRoleConfig {
pub provider: Option<String>,
pub skill: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct AgentsConfig {
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default)]
pub product_owner: AgentRoleConfig,
#[serde(default)]
pub qa_engineer: AgentRoleConfig,
#[serde(default)]
pub developer: AgentRoleConfig,
#[serde(default)]
pub reviewer: AgentRoleConfig,
}
impl AgentsConfig {
pub fn provider_for_role(&self, role: &str) -> String {
let config = match role {
"product_owner" => &self.product_owner,
"qa_engineer" => &self.qa_engineer,
"developer" => &self.developer,
"reviewer" => &self.reviewer,
_ => return self.provider.clone(),
};
config
.provider
.clone()
.unwrap_or_else(|| self.provider.clone())
}
pub fn skill_for_role(&self, role: &str) -> String {
let config = match role {
"product_owner" => &self.product_owner,
"qa_engineer" => &self.qa_engineer,
"developer" => &self.developer,
"reviewer" => &self.reviewer,
_ => return String::new(),
};
if let Some(ref skill) = config.skill {
return skill.clone();
}
let provider_name = self.provider_for_role(role);
let provider = providers::from_name(&provider_name);
provider.instruction_dir(role)
}
pub fn all_roles() -> [&'static str; 4] {
["product_owner", "qa_engineer", "developer", "reviewer"]
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LimitsConfig {
#[serde(default = "default_max_iterations")]
pub max_iterations: u32,
#[serde(default = "default_max_retries")]
pub max_retries_per_step: u32,
#[serde(default = "default_max_reject_cycles")]
pub max_reject_cycles: u32,
#[serde(default = "default_agent_timeout")]
pub agent_timeout_seconds: u64,
#[serde(default = "default_max_wall_time")]
pub max_wall_time_seconds: u64,
#[serde(default = "default_retry_delay_base")]
pub retry_delay_base_seconds: u64,
#[serde(default = "default_groom_max_iterations")]
pub groom_max_iterations: u32,
#[serde(default = "default_inject_feedback")]
pub inject_feedback_on_retry: bool,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct HooksConfig {
pub post_qa: Option<String>,
pub post_dev: Option<String>,
pub post_reviewer: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct GitConfig {
#[serde(default = "default_git_enabled")]
pub enabled: bool,
}
fn default_stories_dir() -> String {
".regista/stories".into()
}
fn default_story_pattern() -> String {
"STORY-*.md".into()
}
fn default_epics_dir() -> String {
".regista/epics".into()
}
fn default_decisions_dir() -> String {
".regista/decisions".into()
}
fn default_log_dir() -> String {
".regista/logs".into()
}
fn default_provider() -> String {
"pi".into()
}
fn default_max_iterations() -> u32 {
0
}
fn default_max_retries() -> u32 {
5
}
fn default_max_reject_cycles() -> u32 {
3
}
fn default_agent_timeout() -> u64 {
1800
}
fn default_max_wall_time() -> u64 {
28800
}
fn default_retry_delay_base() -> u64 {
10
}
fn default_groom_max_iterations() -> u32 {
5
}
fn default_inject_feedback() -> bool {
true
}
fn default_git_enabled() -> bool {
true
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
stories_dir: default_stories_dir(),
story_pattern: default_story_pattern(),
epics_dir: default_epics_dir(),
decisions_dir: default_decisions_dir(),
log_dir: default_log_dir(),
}
}
}
impl Default for AgentsConfig {
fn default() -> Self {
Self {
provider: default_provider(),
product_owner: AgentRoleConfig::default(),
qa_engineer: AgentRoleConfig::default(),
developer: AgentRoleConfig::default(),
reviewer: AgentRoleConfig::default(),
}
}
}
impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_iterations: default_max_iterations(),
max_retries_per_step: default_max_retries(),
max_reject_cycles: default_max_reject_cycles(),
agent_timeout_seconds: default_agent_timeout(),
max_wall_time_seconds: default_max_wall_time(),
retry_delay_base_seconds: default_retry_delay_base(),
groom_max_iterations: default_groom_max_iterations(),
inject_feedback_on_retry: default_inject_feedback(),
}
}
}
impl Default for GitConfig {
fn default() -> Self {
Self {
enabled: default_git_enabled(),
}
}
}
impl Config {
pub fn load(project_root: &Path, config_path: Option<&Path>) -> anyhow::Result<Self> {
let default_config_path = project_root.join(".regista/config.toml");
let config_path = config_path.unwrap_or(&default_config_path);
let config = if config_path.exists() {
let content = std::fs::read_to_string(config_path)?;
toml::from_str(&content)?
} else {
tracing::warn!(
"No se encontró {} — usando configuración por defecto",
config_path.display()
);
Config::default()
};
config.validate(project_root)?;
Ok(config)
}
fn validate(&self, project_root: &Path) -> anyhow::Result<()> {
let stories_path = project_root.join(&self.project.stories_dir);
if !stories_path.exists() {
anyhow::bail!(
"El directorio de historias no existe: {}",
stories_path.display()
);
}
if !stories_path.is_dir() {
anyhow::bail!(
"La ruta de historias no es un directorio: {}",
stories_path.display()
);
}
for dir in [&self.project.decisions_dir, &self.project.log_dir] {
let path = project_root.join(dir);
std::fs::create_dir_all(&path)?;
}
Ok(())
}
#[allow(dead_code)]
pub fn resolve(&self, project_root: &Path, relative: &str) -> PathBuf {
project_root.join(relative)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
let cfg = Config::default();
assert_eq!(cfg.project.stories_dir, ".regista/stories");
assert_eq!(cfg.project.story_pattern, "STORY-*.md");
assert_eq!(cfg.agents.provider, "pi");
assert_eq!(cfg.limits.max_iterations, 0);
assert_eq!(cfg.limits.max_retries_per_step, 5);
assert_eq!(cfg.limits.max_reject_cycles, 3);
assert_eq!(cfg.limits.agent_timeout_seconds, 1800);
assert!(cfg.hooks.post_qa.is_none());
assert!(cfg.git.enabled);
}
#[test]
fn default_skill_for_role_uses_pi_convention() {
let cfg = Config::default();
assert_eq!(
cfg.agents.skill_for_role("product_owner"),
".pi/skills/product-owner/SKILL.md"
);
assert_eq!(
cfg.agents.skill_for_role("qa_engineer"),
".pi/skills/qa-engineer/SKILL.md"
);
assert_eq!(
cfg.agents.skill_for_role("developer"),
".pi/skills/developer/SKILL.md"
);
}
#[test]
fn default_provider_for_role_is_pi() {
let cfg = Config::default();
for role in AgentsConfig::all_roles() {
assert_eq!(cfg.agents.provider_for_role(role), "pi");
}
}
#[test]
fn parse_minimal_config_just_provider() {
let toml = r#"
[agents]
provider = "claude"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.agents.provider, "claude");
assert_eq!(cfg.agents.provider_for_role("product_owner"), "claude");
assert_eq!(cfg.agents.provider_for_role("developer"), "claude");
}
#[test]
fn parse_role_specific_provider() {
let toml = r#"
[agents]
provider = "claude"
[agents.developer]
provider = "pi"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.agents.provider_for_role("product_owner"), "claude");
assert_eq!(cfg.agents.provider_for_role("developer"), "pi");
}
#[test]
fn parse_explicit_skill_path() {
let toml = r#"
[agents]
provider = "pi"
[agents.reviewer]
skill = ".pi/skills/senior-reviewer/SKILL.md"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(
cfg.agents.skill_for_role("reviewer"),
".pi/skills/senior-reviewer/SKILL.md"
);
assert_eq!(
cfg.agents.skill_for_role("developer"),
".pi/skills/developer/SKILL.md"
);
}
#[test]
fn parse_mixed_providers_with_explicit_skills() {
let toml = r#"
[agents]
provider = "pi"
[agents.product_owner]
provider = "claude"
skill = ".claude/agents/po-custom.md"
[agents.developer]
provider = "codex"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.agents.provider_for_role("product_owner"), "claude");
assert_eq!(
cfg.agents.skill_for_role("product_owner"),
".claude/agents/po-custom.md"
);
assert_eq!(cfg.agents.provider_for_role("developer"), "codex");
assert_eq!(
cfg.agents.skill_for_role("developer"),
".agents/skills/developer/SKILL.md"
);
assert_eq!(cfg.agents.provider_for_role("qa_engineer"), "pi");
assert_eq!(cfg.agents.provider_for_role("reviewer"), "pi");
}
#[test]
fn parse_full_config() {
let toml = r#"
[project]
stories_dir = "docs/stories"
story_pattern = "*.md"
[agents]
provider = "claude"
[limits]
max_iterations = 5
max_retries_per_step = 3
[hooks]
post_dev = "cargo test"
[git]
enabled = false
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.project.stories_dir, "docs/stories");
assert_eq!(cfg.agents.provider, "claude");
assert_eq!(cfg.limits.max_iterations, 5);
assert_eq!(cfg.hooks.post_dev.as_deref(), Some("cargo test"));
assert!(!cfg.git.enabled);
}
}