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)]
#[serde(default)]
pub struct AgentsConfig {
#[serde(default = "default_po_skill")]
pub product_owner: String,
#[serde(default = "default_qa_skill")]
pub qa_engineer: String,
#[serde(default = "default_dev_skill")]
pub developer: String,
#[serde(default = "default_reviewer_skill")]
pub reviewer: String,
}
#[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 {
"product/stories".into()
}
fn default_story_pattern() -> String {
"STORY-*.md".into()
}
fn default_epics_dir() -> String {
"product/epics".into()
}
fn default_decisions_dir() -> String {
"product/decisions".into()
}
fn default_log_dir() -> String {
"product/logs".into()
}
fn default_po_skill() -> String {
".pi/skills/product-owner/SKILL.md".into()
}
fn default_qa_skill() -> String {
".pi/skills/qa-engineer/SKILL.md".into()
}
fn default_dev_skill() -> String {
".pi/skills/developer/SKILL.md".into()
}
fn default_reviewer_skill() -> String {
".pi/skills/reviewer/SKILL.md".into()
}
fn default_max_iterations() -> u32 {
10
}
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 {
product_owner: default_po_skill(),
qa_engineer: default_qa_skill(),
developer: default_dev_skill(),
reviewer: default_reviewer_skill(),
}
}
}
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.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, "product/stories");
assert_eq!(cfg.project.story_pattern, "STORY-*.md");
assert_eq!(
cfg.agents.product_owner,
".pi/skills/product-owner/SKILL.md"
);
assert_eq!(cfg.limits.max_iterations, 10);
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 parse_minimal_config() {
let toml = r#"
[agents]
product_owner = "skills/po.md"
qa_engineer = "skills/qa.md"
developer = "skills/dev.md"
reviewer = "skills/rev.md"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.agents.product_owner, "skills/po.md");
assert_eq!(cfg.agents.qa_engineer, "skills/qa.md");
assert_eq!(cfg.agents.developer, "skills/dev.md");
assert_eq!(cfg.agents.reviewer, "skills/rev.md");
assert_eq!(cfg.project.stories_dir, "product/stories");
assert_eq!(cfg.limits.max_iterations, 10);
}
#[test]
fn parse_full_config() {
let toml = r#"
[project]
stories_dir = "docs/stories"
story_pattern = "*.md"
epics_dir = "docs/epics"
decisions_dir = "docs/decisions"
log_dir = "docs/logs"
[agents]
product_owner = "a.md"
qa_engineer = "b.md"
developer = "c.md"
reviewer = "d.md"
[limits]
max_iterations = 5
max_retries_per_step = 3
max_reject_cycles = 2
agent_timeout_seconds = 600
max_wall_time_seconds = 3600
retry_delay_base_seconds = 5
[hooks]
post_qa = "echo qa"
post_dev = "echo dev"
post_reviewer = "echo rev"
[git]
enabled = false
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.project.stories_dir, "docs/stories");
assert_eq!(cfg.project.story_pattern, "*.md");
assert_eq!(cfg.limits.max_iterations, 5);
assert_eq!(cfg.hooks.post_qa.as_deref(), Some("echo qa"));
assert!(!cfg.git.enabled);
}
}