use crate::config::{RepoMode, ResolvedConfig};
use crate::data;
use crate::entity::{self, EntityKind};
use crate::error::{McError, McResult};
use crate::frontmatter;
use colored::*;
use regex::Regex;
use serde::Serialize;
use std::path::Path;
use walkdir::WalkDir;
#[derive(Serialize)]
pub struct ValidationIssue {
pub path: String,
pub check: String,
pub message: String,
}
pub fn run(cfg: &ResolvedConfig) -> McResult<()> {
println!("{} Validating repo...\n", "⟳".blue());
let issues = validate_programmatic(cfg)?;
if issues.is_empty() {
println!("{} All checks passed!", "✓".green().bold());
Ok(())
} else {
println!("{} {} issue(s) found:\n", "✗".red().bold(), issues.len());
for (i, issue) in issues.iter().enumerate() {
println!(
" {}. [{}] {}\n {}",
(i + 1).to_string().red(),
issue.check.yellow(),
issue.path.dimmed(),
issue.message
);
}
Err(McError::ValidationFailed(issues.len()))
}
}
pub fn validate_programmatic(cfg: &ResolvedConfig) -> McResult<Vec<ValidationIssue>> {
let mut issues: Vec<ValidationIssue> = Vec::new();
if cfg.mode == RepoMode::Standalone {
validate_entity_dirs(EntityKind::Customer, cfg, &mut issues)?;
validate_entity_dirs(EntityKind::Project, cfg, &mut issues)?;
validate_contacts(cfg, &mut issues)?;
}
validate_meetings(cfg, &mut issues)?;
validate_entity_dirs(EntityKind::Research, cfg, &mut issues)?;
validate_entity_dirs(EntityKind::Sprint, cfg, &mut issues)?;
validate_proposals(cfg, &mut issues)?;
validate_tasks(cfg, &mut issues)?;
Ok(issues)
}
fn validate_entity_dirs(
kind: EntityKind,
cfg: &ResolvedConfig,
issues: &mut Vec<ValidationIssue>,
) -> McResult<()> {
let base = kind.base_dir(cfg);
let prefix = kind.prefix(cfg);
if !base.is_dir() {
return Ok(());
}
let dir_re = Regex::new(&format!(
r"^{}-\d{{3}}-[a-z0-9]+(-[a-z0-9]+)*$",
regex::escape(prefix)
))
.expect("regex with escaped prefix is always valid");
let id_re = Regex::new(&format!(r"^({}-\d+)", regex::escape(prefix)))
.expect("regex with escaped prefix is always valid");
for entry in std::fs::read_dir(base)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().to_string();
if !dir_re.is_match(&dir_name) {
issues.push(ValidationIssue {
path: dir_name.clone(),
check: "folder-naming".into(),
message: format!(
"Directory name does not match expected pattern: {}-NNN-slug",
prefix
),
});
}
let index_file = if let Some(caps) = id_re.captures(&dir_name) {
let id_file = entry.path().join(format!("{}.md", &caps[1]));
if id_file.is_file() {
id_file
} else {
match kind {
EntityKind::Project => entry.path().join("overview.md"),
_ => entry.path().join("_index.md"),
}
}
} else {
match kind {
EntityKind::Project => entry.path().join("overview.md"),
_ => entry.path().join("_index.md"),
}
};
if !index_file.is_file() {
issues.push(ValidationIssue {
path: index_file.display().to_string(),
check: "missing-index".into(),
message: "Required index file not found".into(),
});
continue;
}
validate_frontmatter_file(&index_file, kind, prefix, cfg, issues);
}
Ok(())
}
fn validate_meetings(cfg: &ResolvedConfig, issues: &mut Vec<ValidationIssue>) -> McResult<()> {
let base = &cfg.meetings_dir;
if !base.is_dir() {
return Ok(());
}
let filename_re =
Regex::new(r"^\d{4}-\d{2}-\d{2}-.+\.md$").expect("static regex pattern is always valid");
for entry in WalkDir::new(base)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_dir() || path.extension().is_none_or(|e| e != "md") {
continue;
}
let Some(fname) = path.file_name() else {
continue;
};
let filename = fname.to_string_lossy().to_string();
if !filename_re.is_match(&filename) {
issues.push(ValidationIssue {
path: filename.clone(),
check: "meeting-filename".into(),
message: "Meeting filename does not match YYYY-MM-DD-slug.md pattern".into(),
});
}
validate_frontmatter_file(
path,
EntityKind::Meeting,
&cfg.id_prefixes.meeting,
cfg,
issues,
);
}
Ok(())
}
fn validate_proposals(cfg: &ResolvedConfig, issues: &mut Vec<ValidationIssue>) -> McResult<()> {
let base = &cfg.proposals_dir;
if !base.is_dir() {
return Ok(());
}
let prefix = &cfg.id_prefixes.proposal;
let filename_re = Regex::new(&format!(
r"^{}-\d{{3}}-[a-z0-9]+(-[a-z0-9]+)*\.md$",
regex::escape(prefix)
))
.expect("regex with escaped prefix is always valid");
for entry in WalkDir::new(base)
.max_depth(1)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.is_dir() || path.extension().is_none_or(|e| e != "md") {
continue;
}
let Some(fname) = path.file_name() else {
continue;
};
let filename = fname.to_string_lossy().to_string();
if !filename_re.is_match(&filename) {
issues.push(ValidationIssue {
path: filename.clone(),
check: "proposal-filename".into(),
message: format!(
"Proposal filename does not match {}-NNN-slug.md pattern",
prefix
),
});
}
validate_frontmatter_file(path, EntityKind::Proposal, prefix, cfg, issues);
}
Ok(())
}
fn validate_tasks(cfg: &ResolvedConfig, issues: &mut Vec<ValidationIssue>) -> McResult<()> {
let locations = entity::collect_all_task_dirs(cfg);
let prefix = &cfg.id_prefixes.task;
let filename_re = Regex::new(&format!(
r"^{}-\d{{3}}-[a-z0-9]+(-[a-z0-9]+)*\.md$",
regex::escape(prefix)
))
.expect("regex with escaped prefix is always valid");
let active_statuses = ["backlog", "todo", "in-progress", "review"];
let finished_statuses = ["done", "cancelled"];
for loc in &locations {
if !loc.tasks_dir.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&loc.tasks_dir) {
for entry in entries.filter_map(|e| e.ok()) {
if entry.file_type().is_ok_and(|ft| ft.is_dir()) {
let name = entry.file_name().to_string_lossy().to_string();
if name != "todo" && name != "done" {
issues.push(ValidationIssue {
path: entry.path().display().to_string(),
check: "task-subfolder".into(),
message: format!(
"Unexpected subfolder '{}' in tasks directory (expected only 'todo' and 'done')",
name
),
});
}
}
}
}
for (subfolder, expected_statuses) in &[
("todo", active_statuses.as_slice()),
("done", finished_statuses.as_slice()),
] {
let dir = loc.tasks_dir.join(subfolder);
if !dir.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_none_or(|e| e != "md") {
continue;
}
let Some(fname) = path.file_name() else {
continue;
};
let filename = fname.to_string_lossy().to_string();
if !filename_re.is_match(&filename) {
issues.push(ValidationIssue {
path: path.display().to_string(),
check: "task-filename".into(),
message: format!(
"Task filename does not match {}-NNN-slug.md pattern",
prefix
),
});
}
validate_task_frontmatter_file(&path, prefix, cfg, expected_statuses, issues);
}
}
}
}
Ok(())
}
fn validate_contacts(cfg: &ResolvedConfig, issues: &mut Vec<ValidationIssue>) -> McResult<()> {
let locations = entity::collect_all_contact_dirs(cfg);
let prefix = &cfg.id_prefixes.contact;
let filename_re = Regex::new(&format!(
r"^{}-\d{{3}}-[a-z0-9]+(-[a-z0-9]+)*\.md$",
regex::escape(prefix)
))
.expect("regex with escaped prefix is always valid");
for loc in &locations {
if !loc.contacts_dir.is_dir() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&loc.contacts_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_none_or(|e| e != "md") {
continue;
}
let Some(fname) = path.file_name() else {
continue;
};
let filename = fname.to_string_lossy().to_string();
if !filename_re.is_match(&filename) {
issues.push(ValidationIssue {
path: path.display().to_string(),
check: "contact-filename".into(),
message: format!(
"Contact filename does not match {}-NNN-slug.md pattern",
prefix
),
});
}
validate_frontmatter_file(&path, EntityKind::Contact, prefix, cfg, issues);
}
}
}
Ok(())
}
fn validate_task_frontmatter_file(
path: &Path,
prefix: &str,
cfg: &ResolvedConfig,
expected_statuses: &[&str],
issues: &mut Vec<ValidationIssue>,
) {
let path_str = path.display().to_string();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => {
issues.push(ValidationIssue {
path: path_str,
check: "read-error".into(),
message: "Could not read file".into(),
});
return;
}
};
let (fm_str, _body) = match frontmatter::split_frontmatter(&content) {
Some(parts) => parts,
None => {
issues.push(ValidationIssue {
path: path_str,
check: "frontmatter-presence".into(),
message: "No YAML frontmatter found".into(),
});
return;
}
};
let fm = match frontmatter::parse_raw(&fm_str, path) {
Ok(v) => v,
Err(_) => {
issues.push(ValidationIssue {
path: path_str,
check: "yaml-validity".into(),
message: "Invalid YAML in frontmatter".into(),
});
return;
}
};
let id = match frontmatter::get_str(&fm, "id") {
Some(id) => id.to_string(),
None => {
issues.push(ValidationIssue {
path: path_str,
check: "required-fields".into(),
message: "Missing required 'id' field".into(),
});
return;
}
};
if !id.starts_with(&format!("{}-", prefix)) {
issues.push(ValidationIssue {
path: path_str.clone(),
check: "id-consistency".into(),
message: format!(
"ID '{}' does not start with expected prefix '{}-'",
id, prefix
),
});
}
if frontmatter::get_str(&fm, "title").is_none() {
issues.push(ValidationIssue {
path: path_str.clone(),
check: "required-fields".into(),
message: "Missing required 'title' field".into(),
});
}
if let Some(status) = frontmatter::get_str(&fm, "status") {
let valid_statuses = EntityKind::Task.statuses(cfg);
if !valid_statuses.iter().any(|s| s == status) {
issues.push(ValidationIssue {
path: path_str.clone(),
check: "status-validity".into(),
message: format!(
"Invalid status '{}', expected one of: {}",
status,
valid_statuses.join(", ")
),
});
}
if !expected_statuses.contains(&status) {
let folder = if expected_statuses.contains(&"backlog") {
"todo"
} else {
"done"
};
issues.push(ValidationIssue {
path: path_str.clone(),
check: "folder-status-sync".into(),
message: format!(
"Task with status '{}' is in '{}/' folder but should be in '{}'",
status,
folder,
if folder == "todo" { "done/" } else { "todo/" }
),
});
}
}
if let Some(priority) = data::get_number(&fm, "priority") {
if !(1..=4).contains(&priority) {
issues.push(ValidationIssue {
path: path_str.clone(),
check: "priority-range".into(),
message: format!(
"Priority {} is out of range (expected 1-4: 1=critical, 2=high, 3=medium, 4=low)",
priority
),
});
}
}
}
fn validate_frontmatter_file(
path: &Path,
kind: EntityKind,
prefix: &str,
cfg: &ResolvedConfig,
issues: &mut Vec<ValidationIssue>,
) {
let path_str = path.display().to_string();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => {
issues.push(ValidationIssue {
path: path_str,
check: "read-error".into(),
message: "Could not read file".into(),
});
return;
}
};
let (fm_str, _body) = match frontmatter::split_frontmatter(&content) {
Some(parts) => parts,
None => {
issues.push(ValidationIssue {
path: path_str,
check: "frontmatter-presence".into(),
message: "No YAML frontmatter found".into(),
});
return;
}
};
let fm = match frontmatter::parse_raw(&fm_str, path) {
Ok(v) => v,
Err(_) => {
issues.push(ValidationIssue {
path: path_str,
check: "yaml-validity".into(),
message: "Invalid YAML in frontmatter".into(),
});
return;
}
};
let id = match frontmatter::get_str(&fm, "id") {
Some(id) => id.to_string(),
None => {
issues.push(ValidationIssue {
path: path_str,
check: "required-fields".into(),
message: "Missing required 'id' field".into(),
});
return;
}
};
if !id.starts_with(&format!("{}-", prefix)) {
issues.push(ValidationIssue {
path: path_str.clone(),
check: "id-consistency".into(),
message: format!(
"ID '{}' does not start with expected prefix '{}-'",
id, prefix
),
});
}
let has_name = match kind {
EntityKind::Customer | EntityKind::Project | EntityKind::Contact => {
frontmatter::get_str(&fm, "name").is_some()
}
EntityKind::Meeting
| EntityKind::Research
| EntityKind::Task
| EntityKind::Sprint
| EntityKind::Proposal => frontmatter::get_str(&fm, "title").is_some(),
};
if !has_name {
let field = match kind {
EntityKind::Customer | EntityKind::Project | EntityKind::Contact => "name",
_ => "title",
};
issues.push(ValidationIssue {
path: path_str.clone(),
check: "required-fields".into(),
message: format!("Missing required '{}' field", field),
});
}
if let Some(status) = frontmatter::get_str(&fm, "status") {
let valid_statuses = kind.statuses(cfg);
if !valid_statuses.iter().any(|s| s == status) {
issues.push(ValidationIssue {
path: path_str.clone(),
check: "status-validity".into(),
message: format!(
"Invalid status '{}', expected one of: {}",
status,
valid_statuses.join(", ")
),
});
}
}
if kind != EntityKind::Meeting
&& kind != EntityKind::Task
&& kind != EntityKind::Sprint
&& kind != EntityKind::Proposal
&& kind != EntityKind::Contact
{
if let Some(slug) = frontmatter::get_str(&fm, "slug") {
if let Some(parent) = path.parent() {
let dir_name = parent.file_name().unwrap_or_default().to_string_lossy();
if !dir_name.contains(slug) {
issues.push(ValidationIssue {
path: path_str,
check: "slug-consistency".into(),
message: format!(
"Slug '{}' does not match directory name '{}'",
slug, dir_name
),
});
}
}
}
}
}