use crate::agent::{self, AgentOptions};
use crate::config::Config;
use crate::providers;
use crate::validator;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct GroomResult {
pub stories_created: usize,
pub epics_created: usize,
pub iterations: u32,
pub dependencies_clean: bool,
}
pub fn run(
project_root: &Path,
spec_path: &Path,
cfg: &Config,
max_stories: u32,
replace: bool,
) -> anyhow::Result<GroomResult> {
if !spec_path.exists() {
anyhow::bail!(
"El archivo de especificaciΓ³n no existe: {}",
spec_path.display()
);
}
if !spec_path.is_file() {
anyhow::bail!(
"La ruta de especificaciΓ³n no es un archivo: {}",
spec_path.display()
);
}
let stories_dir = project_root.join(&cfg.project.stories_dir);
let epics_dir = project_root.join(&cfg.project.epics_dir);
let decisions_dir = project_root.join(&cfg.project.decisions_dir);
if replace {
if stories_dir.exists() {
tracing::info!("π§Ή Limpiando {} ...", stories_dir.display());
std::fs::remove_dir_all(&stories_dir)?;
}
if epics_dir.exists() {
tracing::info!("π§Ή Limpiando {} ...", epics_dir.display());
std::fs::remove_dir_all(&epics_dir)?;
}
}
std::fs::create_dir_all(&stories_dir)?;
std::fs::create_dir_all(&epics_dir)?;
std::fs::create_dir_all(&decisions_dir)?;
let snapshot_hash = if cfg.git.enabled {
crate::git::snapshot(project_root, "groom-start")
} else {
None
};
let provider_name = cfg.agents.provider_for_role("product_owner");
let provider = providers::from_name(&provider_name);
let skill_path_str = cfg.agents.skill_for_role("product_owner");
let skill_path = project_root.join(&skill_path_str);
let max_loop = cfg.limits.groom_max_iterations.max(1);
let spec_content = std::fs::read_to_string(spec_path)?;
let ctx = GroomCtx {
spec_path,
spec_content: &spec_content,
stories_dir: &stories_dir,
epics_dir: &epics_dir,
decisions_dir: &decisions_dir,
story_pattern: &cfg.project.story_pattern,
max_stories,
};
let mut loop_iteration: u32 = 0;
let mut stories_count: usize = 0;
let mut epics_count: usize = 0;
let mut deps_clean = false;
loop {
loop_iteration += 1;
let prompt = if loop_iteration == 1 {
groom_prompt_initial(&ctx)
} else {
let config_path = project_root.join(".regista.toml");
let config_path_opt = if config_path.exists() {
Some(config_path.as_path())
} else {
None
};
let validation = validator::validate(project_root, config_path_opt);
let dep_errors: Vec<String> = validation
.findings
.iter()
.filter(|f| {
f.severity == validator::Severity::Error && f.category == "dependencies"
})
.map(|f| f.message.clone())
.collect();
if dep_errors.is_empty() {
tracing::info!(
"β
Grafo de dependencias correcto tras {loop_iteration} iteraciones."
);
deps_clean = true;
break;
}
if loop_iteration > max_loop {
tracing::warn!(
"β οΈ MΓ‘ximo de {max_loop} iteraciones alcanzado. El grafo aΓΊn tiene errores."
);
for err in &dep_errors {
tracing::warn!(" β’ {err}");
}
break;
}
tracing::info!(
"π IteraciΓ³n {loop_iteration}/{max_loop}: corrigiendo {} errores...",
dep_errors.len()
);
groom_prompt_fix(&ctx, &dep_errors)
};
tracing::info!("π€ Invocando PO para generar/corregir historias...");
match agent::invoke_with_retry(
provider.as_ref(),
&skill_path,
&prompt,
&cfg.limits,
&AgentOptions::default(),
) {
Ok(_) => {
stories_count = count_files(&stories_dir, &cfg.project.story_pattern);
epics_count = count_files(&epics_dir, "EPIC-*.md");
tracing::info!(" π Generadas: {stories_count} historias, {epics_count} Γ©picas");
if stories_count == 0 && loop_iteration == 1 {
tracing::warn!(
"β οΈ El PO no generΓ³ ninguna historia. ΒΏEl skill tiene permisos de escritura?"
);
}
}
Err(e) => {
tracing::error!("β FallΓ³ la invocaciΓ³n del PO: {e}");
if let Some(ref hash) = snapshot_hash {
crate::git::rollback(project_root, hash, "groom-failed");
}
anyhow::bail!("Groom fallΓ³: {e}");
}
}
}
Ok(GroomResult {
stories_created: stories_count,
epics_created: epics_count,
iterations: loop_iteration,
dependencies_clean: deps_clean,
})
}
struct GroomCtx<'a> {
spec_path: &'a Path,
spec_content: &'a str,
stories_dir: &'a Path,
epics_dir: &'a Path,
decisions_dir: &'a Path,
story_pattern: &'a str,
max_stories: u32,
}
fn groom_prompt_initial(ctx: &GroomCtx) -> String {
let limit_line = if ctx.max_stories > 0 {
format!(
"\nGenera como **mΓ‘ximo {} historias** en total.\n",
ctx.max_stories
)
} else {
String::new()
};
format!(
"Eres un Product Owner. Tu tarea es descomponer una especificaciΓ³n \
de producto en historias de usuario atΓ³micas y Γ©picas.\n\
\n\
## EspecificaciΓ³n fuente\n\
Archivo: {spec_path}\n\
\n\
```\n\
{spec_content}\n\
```\n\
\n\
## Instrucciones\n\
\n\
1. Lee la especificaciΓ³n completa.\n\
2. Identifica **Γ©picas** (grupos de funcionalidades relacionadas).\n\
3. Para cada Γ©pica, descompΓ³n en **historias de usuario atΓ³micas**.\n\
4. Cada historia debe ser pequeΓ±a, independiente, y entregar valor.\n\
{limit_line}\
\n\
## Formato de cada historia (archivo STORY-NNN.md en {stories_dir})\n\
\n\
```markdown\n\
# STORY-NNN: TΓtulo descriptivo\n\
\n\
## Status\n\
**Draft**\n\
\n\
## Epic\n\
EPIC-XXX\n\
\n\
## DescripciΓ³n\n\
[DescripciΓ³n clara de la funcionalidad. No ambigua.]\n\
\n\
## Criterios de aceptaciΓ³n\n\
- [ ] CA1: criterio especΓfico y verificable\n\
- [ ] CA2: ...\n\
\n\
## Dependencias\n\
- Bloqueado por: STORY-XXX, STORY-YYY\n\
\n\
## Activity Log\n\
- [FECHA] | PO | Historia generada desde {spec_path}.\n\
```\n\
\n\
## Formato de cada Γ©pica (archivo EPIC-NNN.md en {epics_dir})\n\
\n\
```markdown\n\
# EPIC-NNN: TΓtulo de la Γ©pica\n\
\n\
## DescripciΓ³n\n\
[DescripciΓ³n de la Γ©pica]\n\
\n\
## Historias\n\
- STORY-XXX\n\
- STORY-YYY\n\
```\n\
\n\
## Reglas importantes\n\
\n\
- Los IDs de historia deben seguir el patrΓ³n {story_pattern}.\n\
- Los criterios de aceptaciΓ³n deben ser **especΓficos y testeables**.\n\
Nada de \"debe funcionar bien\". SΓ© concreto.\n\
- Si una historia depende de otra, indΓcalo en \"Bloqueado por:\".\n\
Solo referenciar historias que TΓ has creado en esta sesiΓ³n.\n\
- Cada historia comienza en estado **Draft**.\n\
- El Activity Log debe tener una entrada inicial con la fecha de hoy.\n\
- Documenta las decisiones de diseΓ±o del backlog en {decisions_dir}/groom-decision.md.\n\
- Escribe los archivos reales en el filesystem. No los imprimas en pantalla.\n\
- **NO preguntes nada al usuario. Trabaja de forma 100% autΓ³noma.**\n\
\n\
Empieza ya. Lee la spec, descompΓ³n, y escribe los archivos.",
spec_path = ctx.spec_path.display(),
spec_content = ctx.spec_content,
stories_dir = ctx.stories_dir.display(),
epics_dir = ctx.epics_dir.display(),
decisions_dir = ctx.decisions_dir.display(),
story_pattern = ctx.story_pattern,
limit_line = limit_line,
)
}
fn groom_prompt_fix(ctx: &GroomCtx, errors: &[String]) -> String {
let limit_line = if ctx.max_stories > 0 {
format!(
"\nNo generes mΓ‘s de {} historias en total.\n",
ctx.max_stories
)
} else {
String::new()
};
let errors_formatted: String = errors
.iter()
.enumerate()
.map(|(i, e)| format!("{}. {e}", i + 1))
.collect::<Vec<_>>()
.join("\n");
format!(
"Eres un Product Owner. Las historias que generaste desde la especificaciΓ³n \
tienen **errores en el grafo de dependencias**. Debes corregirlos.\n\
\n\
## EspecificaciΓ³n original\n\
Archivo: {spec_path}\n\
\n\
```\n\
{spec_content}\n\
```\n\
\n\
## Errores de dependencias detectados\n\
\n\
{errors_formatted}\n\
\n\
## Lo que debes hacer\n\
\n\
1. Lee los archivos de historia en {stories_dir}.\n\
2. Corrige **solo los archivos que tengan errores** de dependencias:\n\
- Si una historia referencia un ID que no existe, elimina o corrige la referencia.\n\
- Si hay un ciclo de dependencias, rompe el ciclo eliminando la dependencia menos crΓtica.\n\
- NO borres historias completas a menos que sean redundantes.\n\
3. Actualiza el Activity Log de cada historia modificada:\n\
`- [FECHA] | PO | Corregidas dependencias tras validaciΓ³n.`\n\
4. Si eliminaste dependencias, asegΓΊrate de que las Γ©picas en {epics_dir} sigan siendo correctas.\n\
{limit_line}\
\n\
## Reglas\n\
- Solo modifica archivos existentes. No crees nuevas historias a menos que sea inevitable.\n\
- Los IDs deben seguir el patrΓ³n {story_pattern}.\n\
- Documenta los cambios en {decisions_dir}/groom-correcciones.md.\n\
- **NO preguntes nada al usuario. 100% autΓ³nomo.**\n\
\n\
Corrige los errores ahora.",
spec_path = ctx.spec_path.display(),
spec_content = ctx.spec_content,
stories_dir = ctx.stories_dir.display(),
epics_dir = ctx.epics_dir.display(),
decisions_dir = ctx.decisions_dir.display(),
story_pattern = ctx.story_pattern,
errors_formatted = errors_formatted,
limit_line = limit_line,
)
}
fn count_files(dir: &Path, pattern: &str) -> usize {
let full_pattern = dir.join(pattern);
match glob::glob(full_pattern.to_str().unwrap_or("*.md")) {
Ok(entries) => entries.filter_map(|e| e.ok()).count(),
Err(_) => 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_fixture() -> GroomCtx<'static> {
let spec_content: &'static str = "contenido de prueba";
let spec_path = Path::new("spec.md");
let stories_dir = Path::new("stories");
let epics_dir = Path::new("epics");
let decisions_dir = Path::new("decisions");
let story_pattern = "STORY-*.md";
GroomCtx {
spec_path,
spec_content,
stories_dir,
epics_dir,
decisions_dir,
story_pattern,
max_stories: 0,
}
}
#[test]
fn count_files_counts_md_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("STORY-001.md"), "test").unwrap();
std::fs::write(tmp.path().join("STORY-002.md"), "test").unwrap();
std::fs::write(tmp.path().join("NOTES.txt"), "test").unwrap();
assert_eq!(count_files(tmp.path(), "STORY-*.md"), 2);
}
#[test]
fn count_files_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(count_files(tmp.path(), "STORY-*.md"), 0);
}
#[test]
fn groom_prompt_initial_contains_spec() {
let prompt = groom_prompt_initial(&ctx_fixture());
assert!(prompt.contains("contenido de prueba"));
assert!(prompt.contains("stories"));
assert!(prompt.contains("NO preguntes"));
}
#[test]
fn groom_prompt_initial_respects_max_stories() {
let mut ctx = ctx_fixture();
ctx.max_stories = 10;
let prompt = groom_prompt_initial(&ctx);
assert!(prompt.contains("mΓ‘ximo 10 historias"));
}
#[test]
fn groom_prompt_initial_no_limit_when_zero() {
let prompt = groom_prompt_initial(&ctx_fixture());
assert!(!prompt.contains("mΓ‘ximo"));
}
#[test]
fn groom_prompt_fix_includes_errors() {
let errors = vec![
"STORY-003: referencia a STORY-999 que no existe".to_string(),
"Ciclo entre STORY-005 y STORY-007".to_string(),
];
let prompt = groom_prompt_fix(&ctx_fixture(), &errors);
assert!(prompt.contains("STORY-003"));
assert!(prompt.contains("STORY-999"));
assert!(prompt.contains("Ciclo"));
assert!(prompt.contains("NO preguntes"));
}
}