use crate::config::{AgentsConfig, Config};
use crate::dependency_graph::DependencyGraph;
use crate::state::Status;
use crate::story::Story;
use serde::Serialize;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
#[serde(rename = "error")]
Error,
#[serde(rename = "warning")]
Warning,
}
#[derive(Debug, Clone, Serialize)]
pub struct Finding {
pub severity: Severity,
pub category: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub story_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ValidationResult {
pub ok: usize,
pub warnings: usize,
pub errors: usize,
pub findings: Vec<Finding>,
}
impl ValidationResult {
fn add(
&mut self,
severity: Severity,
category: &str,
message: String,
story_id: Option<String>,
) {
match severity {
Severity::Error => self.errors += 1,
Severity::Warning => self.warnings += 1,
}
self.findings.push(Finding {
severity,
category: category.to_string(),
message,
story_id,
});
}
}
pub fn validate(project_root: &Path, config_path: Option<&Path>) -> ValidationResult {
let mut result = ValidationResult {
ok: 0,
warnings: 0,
errors: 0,
findings: vec![],
};
let cfg = validate_config(project_root, config_path, &mut result);
if let Some(ref cfg) = cfg {
validate_skills(project_root, cfg, &mut result);
}
let stories = if let Some(ref cfg) = cfg {
validate_stories(project_root, cfg, &mut result)
} else {
vec![]
};
if !stories.is_empty() {
validate_dependencies(&stories, &mut result);
}
if let Some(ref cfg) = cfg {
validate_git(project_root, cfg, &mut result);
}
let categories: HashSet<&str> = result
.findings
.iter()
.map(|f| f.category.as_str())
.collect();
let all_categories = ["config", "skills", "stories", "dependencies", "git"];
result.ok = all_categories
.iter()
.filter(|c| !categories.contains(*c))
.count();
result
}
fn validate_config(
project_root: &Path,
config_path: Option<&Path>,
result: &mut ValidationResult,
) -> Option<Config> {
let default_config_path = project_root.join(".regista.toml");
let config_path = config_path.unwrap_or(&default_config_path);
if !config_path.exists() {
result.add(
Severity::Warning,
"config",
format!(
"Archivo {} no encontrado β se usarΓ‘n defaults.",
config_path.display()
),
None,
);
return Some(Config::default());
}
match std::fs::read_to_string(config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(cfg) => {
let stories_path = project_root.join(&cfg.project.stories_dir);
if !stories_path.exists() {
result.add(
Severity::Error,
"config",
format!(
"El directorio de historias '{}' no existe.",
stories_path.display()
),
None,
);
}
Some(cfg)
}
Err(e) => {
result.add(
Severity::Error,
"config",
format!("Error parseando {}: {e}", config_path.display()),
None,
);
None
}
},
Err(e) => {
result.add(
Severity::Error,
"config",
format!("No se pudo leer {}: {e}", config_path.display()),
None,
);
None
}
}
}
fn validate_skills(project_root: &Path, cfg: &Config, result: &mut ValidationResult) {
let roles = AgentsConfig::all_roles();
let role_names = ["PO", "QA", "Dev", "Reviewer"];
let mut found = 0;
for (i, role) in roles.iter().enumerate() {
let path_str = cfg.agents.skill_for_role(role);
let path = project_root.join(&path_str);
let label = role_names[i];
if path.exists() && path.is_file() {
found += 1;
} else {
result.add(
Severity::Error,
"skills",
format!("Skill de {label} no encontrado: {}", path.display()),
None,
);
}
}
if found == roles.len() {
}
}
fn validate_stories(
project_root: &Path,
cfg: &Config,
result: &mut ValidationResult,
) -> Vec<Story> {
let stories_dir = project_root.join(&cfg.project.stories_dir);
if !stories_dir.exists() || !stories_dir.is_dir() {
result.add(
Severity::Error,
"stories",
format!(
"Directorio de historias no accesible: {}",
stories_dir.display()
),
None,
);
return vec![];
}
let pattern = stories_dir.join(&cfg.project.story_pattern);
let mut stories = vec![];
let entries = match glob::glob(pattern.to_str().unwrap_or("*.md")) {
Ok(e) => e,
Err(e) => {
result.add(
Severity::Error,
"stories",
format!("PatrΓ³n glob invΓ‘lido '{}': {e}", cfg.project.story_pattern),
None,
);
return vec![];
}
};
for entry in entries {
let path = match entry {
Ok(p) => p,
Err(e) => {
result.add(
Severity::Warning,
"stories",
format!("Error leyendo entrada: {e}"),
None,
);
continue;
}
};
match Story::load(&path) {
Ok(story) => {
if !story.id.chars().any(|c| c.is_ascii_digit()) {
result.add(
Severity::Warning,
"stories",
format!("{}: ID no contiene nΓΊmero ({})", story.id, path.display()),
Some(story.id.clone()),
);
}
let has_activity_log = story
.raw_content
.lines()
.any(|l| l.to_lowercase().trim().starts_with("## activity log"));
if !has_activity_log {
result.add(
Severity::Warning,
"stories",
format!("{}: no tiene secciΓ³n '## Activity Log'", story.id),
Some(story.id.clone()),
);
}
if story.status == Status::Draft && story.raw_content.is_empty() {
}
stories.push(story);
}
Err(e) => {
let id = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
result.add(
Severity::Error,
"stories",
format!("{id}: error al parsear β {e}"),
Some(id.to_string()),
);
}
}
}
if stories.is_empty() {
result.add(
Severity::Warning,
"stories",
format!("No se encontraron historias en {}", stories_dir.display()),
None,
);
}
stories
}
fn validate_dependencies(stories: &[Story], result: &mut ValidationResult) {
let story_ids: HashSet<&str> = stories.iter().map(|s| s.id.as_str()).collect();
for story in stories {
for blocker in &story.blockers {
if !story_ids.contains(blocker.as_str()) {
result.add(
Severity::Error,
"dependencies",
format!(
"{}: referencia a {} que no existe en {}",
story.id,
blocker,
stories
.first()
.map(|s| s
.path
.parent()
.unwrap_or(Path::new("."))
.display()
.to_string())
.unwrap_or_default()
),
Some(story.id.clone()),
);
}
}
}
let graph = DependencyGraph::from_stories(stories);
if graph.has_any_cycle() {
let cycle_members = graph.find_cycle_members();
let members_str: Vec<String> = {
let mut v: Vec<String> = cycle_members.iter().cloned().collect();
v.sort();
v
};
result.add(
Severity::Error,
"dependencies",
format!(
"Ciclo de dependencias detectado entre: {}",
members_str.join(", ")
),
None,
);
}
}
fn validate_git(project_root: &Path, cfg: &Config, result: &mut ValidationResult) {
if !cfg.git.enabled {
return;
}
if !project_root.join(".git").is_dir() {
result.add(
Severity::Warning,
"git",
"git.enabled = true pero no hay repositorio git. Se auto-inicializarΓ‘.".into(),
None,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Status;
use std::path::PathBuf;
fn story_fixture(id: &str, status: Status, blockers: &[&str]) -> Story {
Story {
id: id.to_string(),
path: PathBuf::from(format!("stories/{id}.md")),
status,
epic: None,
blockers: blockers.iter().map(|s| s.to_string()).collect(),
last_rejection: None,
raw_content: format!(
"# {id}\n\n## Status\n**{status}**\n\n## Activity Log\n- 2026-04-30 | PO | ok\n"
),
}
}
#[test]
fn validate_no_dependency_issues() {
let stories = vec![
story_fixture("STORY-001", Status::Done, &[]),
story_fixture("STORY-002", Status::Ready, &["STORY-001"]),
];
let mut result = ValidationResult {
ok: 0,
warnings: 0,
errors: 0,
findings: vec![],
};
validate_dependencies(&stories, &mut result);
assert_eq!(result.errors, 0);
}
#[test]
fn validate_missing_dependency_detected() {
let stories = vec![story_fixture("STORY-001", Status::Blocked, &["STORY-999"])];
let mut result = ValidationResult {
ok: 0,
warnings: 0,
errors: 0,
findings: vec![],
};
validate_dependencies(&stories, &mut result);
assert!(result.errors > 0);
}
#[test]
fn validate_cycle_detected() {
let stories = vec![
story_fixture("STORY-001", Status::Blocked, &["STORY-002"]),
story_fixture("STORY-002", Status::Blocked, &["STORY-001"]),
];
let mut result = ValidationResult {
ok: 0,
warnings: 0,
errors: 0,
findings: vec![],
};
validate_dependencies(&stories, &mut result);
assert!(result.errors > 0);
assert!(result.findings.iter().any(|f| f.message.contains("Ciclo")));
}
}