use std::collections::{HashMap, HashSet};
use std::sync::OnceLock;
use regex::Regex;
use crate::error::codes::ErrorCode;
use crate::error::diagnostic::{AgmError, ErrorLocation, Severity};
use crate::model::fields::NodeStatus;
use crate::model::node::Node;
static NODE_ID_PATTERN: OnceLock<Regex> = OnceLock::new();
fn node_id_regex() -> &'static Regex {
NODE_ID_PATTERN.get_or_init(|| Regex::new(r"^[a-z][a-z0-9_]*([.\-][a-z][a-z0-9_]*)*$").unwrap())
}
#[must_use]
pub fn validate_node_ids(nodes: &[Node], file_name: &str) -> Vec<AgmError> {
let mut errors = Vec::new();
let mut seen: HashMap<&str, usize> = HashMap::new();
let pattern = node_id_regex();
for node in nodes {
let id = node.id.as_str();
let line = node.span.start_line;
if let Some(&first_line) = seen.get(id) {
errors.push(AgmError::new(
ErrorCode::V003,
format!("Duplicate node ID: `{id}` (first seen at line {first_line})"),
ErrorLocation::full(file_name, line, id),
));
} else {
seen.insert(id, line);
}
if !pattern.is_match(id) {
errors.push(AgmError::new(
ErrorCode::V021,
format!("Node ID does not match required pattern: `{id}`"),
ErrorLocation::full(file_name, line, id),
));
}
}
errors
}
#[must_use]
pub fn validate_node(node: &Node, all_ids: &HashSet<String>, file_name: &str) -> Vec<AgmError> {
let _ = all_ids; let mut errors = Vec::new();
let line = node.span.start_line;
let id = node.id.as_str();
if node.summary.is_empty() {
errors.push(AgmError::new(
ErrorCode::V002,
format!("Node `{id}` missing required field: `summary`"),
ErrorLocation::full(file_name, line, id),
));
return errors;
}
if node.summary.trim().is_empty() {
errors.push(AgmError::with_severity(
ErrorCode::V011,
Severity::Warning,
format!("Summary is empty in node `{id}`"),
ErrorLocation::full(file_name, line, id),
));
}
if node.summary.chars().count() > 200 {
errors.push(AgmError::with_severity(
ErrorCode::V012,
Severity::Warning,
format!("Summary exceeds 200 characters in node `{id}`"),
ErrorLocation::full(file_name, line, id),
));
}
if let (Some(from), Some(until)) = (&node.valid_from, &node.valid_until) {
if from.as_str() > until.as_str() {
errors.push(AgmError::new(
ErrorCode::V007,
format!("`valid_from` is after `valid_until` in node `{id}`"),
ErrorLocation::full(file_name, line, id),
));
}
}
let needs_replaces = matches!(
&node.status,
Some(NodeStatus::Deprecated) | Some(NodeStatus::Superseded)
);
if needs_replaces && node.replaces.is_none() {
errors.push(AgmError::with_severity(
ErrorCode::V014,
Severity::Warning,
format!("Deprecated node `{id}` missing `replaces` or `superseded_by`"),
ErrorLocation::full(file_name, line, id),
));
}
errors
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::model::fields::{NodeStatus, NodeType, Span};
use crate::model::node::Node;
fn minimal_node() -> Node {
Node {
id: "test.node".to_owned(),
node_type: NodeType::Facts,
summary: "a test node".to_owned(),
priority: None,
stability: None,
confidence: None,
status: None,
depends: None,
related_to: None,
replaces: None,
conflicts: None,
see_also: None,
items: None,
steps: None,
fields: None,
input: None,
output: None,
detail: None,
rationale: None,
tradeoffs: None,
resolution: None,
examples: None,
notes: None,
code: None,
code_blocks: None,
verify: None,
agent_context: None,
target: None,
execution_status: None,
executed_by: None,
executed_at: None,
execution_log: None,
retry_count: None,
parallel_groups: None,
memory: None,
scope: None,
applies_when: None,
valid_from: None,
valid_until: None,
tags: None,
aliases: None,
keywords: None,
extra_fields: BTreeMap::new(),
span: Span::new(5, 7),
}
}
#[test]
fn test_validate_node_ids_duplicate_returns_v003() {
let mut n1 = minimal_node();
let mut n2 = minimal_node();
n1.id = "auth.login".to_owned();
n2.id = "auth.login".to_owned();
n2.span = Span::new(10, 12);
let errors = validate_node_ids(&[n1, n2], "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V003));
}
#[test]
fn test_validate_node_id_valid_pattern_returns_empty() {
let mut node = minimal_node();
node.id = "auth.login".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.is_empty());
}
#[test]
fn test_validate_node_id_single_segment_valid() {
let mut node = minimal_node();
node.id = "auth".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.is_empty());
}
#[test]
fn test_validate_node_id_invalid_pattern_uppercase_returns_v021() {
let mut node = minimal_node();
node.id = "Auth.Login".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
}
#[test]
fn test_validate_node_id_leading_dot_returns_v021() {
let mut node = minimal_node();
node.id = ".auth.login".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
}
#[test]
fn test_validate_node_id_trailing_dot_returns_v021() {
let mut node = minimal_node();
node.id = "auth.login.".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
}
#[test]
fn test_validate_node_id_consecutive_dots_returns_v021() {
let mut node = minimal_node();
node.id = "auth..login".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
}
#[test]
fn test_validate_node_id_starts_with_digit_returns_v021() {
let mut node = minimal_node();
node.id = "1auth".to_owned();
let errors = validate_node_ids(&[node], "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V021));
}
#[test]
fn test_validate_node_empty_summary_returns_v002() {
let mut node = minimal_node();
node.summary = String::new();
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V002));
}
#[test]
fn test_validate_node_whitespace_only_summary_returns_v011() {
let mut node = minimal_node();
node.summary = " ".to_owned();
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V011));
}
#[test]
fn test_validate_node_summary_exactly_200_returns_empty() {
let mut node = minimal_node();
node.summary = "a".repeat(200);
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.is_empty(), "200 chars should not trigger V012");
}
#[test]
fn test_validate_node_summary_201_chars_returns_v012() {
let mut node = minimal_node();
node.summary = "a".repeat(201);
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V012));
}
#[test]
fn test_validate_node_valid_from_after_until_returns_v007() {
let mut node = minimal_node();
node.valid_from = Some("2025-12-31".to_owned());
node.valid_until = Some("2025-01-01".to_owned());
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V007));
}
#[test]
fn test_validate_node_valid_from_equals_until_returns_empty() {
let mut node = minimal_node();
node.valid_from = Some("2025-06-01".to_owned());
node.valid_until = Some("2025-06-01".to_owned());
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.is_empty());
}
#[test]
fn test_validate_node_valid_from_before_until_returns_empty() {
let mut node = minimal_node();
node.valid_from = Some("2025-01-01".to_owned());
node.valid_until = Some("2025-12-31".to_owned());
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.is_empty());
}
#[test]
fn test_validate_node_deprecated_no_replaces_returns_v014() {
let mut node = minimal_node();
node.status = Some(NodeStatus::Deprecated);
node.replaces = None;
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V014));
}
#[test]
fn test_validate_node_superseded_no_replaces_returns_v014() {
let mut node = minimal_node();
node.status = Some(NodeStatus::Superseded);
node.replaces = None;
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.iter().any(|e| e.code == ErrorCode::V014));
}
#[test]
fn test_validate_node_superseded_with_replaces_returns_empty() {
let mut node = minimal_node();
node.status = Some(NodeStatus::Superseded);
node.replaces = Some(vec!["other.node".to_owned()]);
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(!errors.iter().any(|e| e.code == ErrorCode::V014));
}
#[test]
fn test_validate_node_valid_node_returns_empty() {
let node = minimal_node();
let all_ids = HashSet::new();
let errors = validate_node(&node, &all_ids, "test.agm");
assert!(errors.is_empty());
}
}