Skip to main content

ito_core/validate/
mod.rs

1//! Validate Ito repository artifacts.
2//!
3//! This module provides lightweight validation helpers for specs, changes, and
4//! modules.
5//!
6//! The primary consumer is the CLI and any APIs that need a structured report
7//! (`ValidationReport`) rather than a single error.
8
9use std::path::{Path, PathBuf};
10
11use crate::error_bridge::IntoCoreResult;
12use crate::errors::{CoreError, CoreResult};
13use serde::Serialize;
14
15use ito_common::paths;
16
17use crate::show::{parse_change_show_json, parse_spec_show_json, read_change_delta_spec_files};
18use ito_domain::changes::ChangeRepository as DomainChangeRepository;
19use ito_domain::modules::ModuleRepository as DomainModuleRepository;
20
21mod issue;
22mod repo_integrity;
23mod report;
24
25pub use issue::{error, info, issue, warning, with_line, with_loc, with_metadata};
26pub use repo_integrity::validate_change_dirs_repo_integrity;
27pub use report::{ReportBuilder, report};
28
29/// Severity level for a [`ValidationIssue`].
30pub type ValidationLevel = &'static str;
31
32/// Validation issue is an error (always fails validation).
33pub const LEVEL_ERROR: ValidationLevel = "ERROR";
34/// Validation issue is a warning (fails validation in strict mode).
35pub const LEVEL_WARNING: ValidationLevel = "WARNING";
36/// Validation issue is informational (never fails validation).
37pub const LEVEL_INFO: ValidationLevel = "INFO";
38
39// Thresholds: match TS defaults.
40const MIN_PURPOSE_LENGTH: usize = 50;
41const MIN_MODULE_PURPOSE_LENGTH: usize = 20;
42const MAX_DELTAS_PER_CHANGE: usize = 10;
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45/// One validation finding.
46pub struct ValidationIssue {
47    /// Issue severity.
48    pub level: String,
49    /// Logical path within the validated artifact (or a filename).
50    pub path: String,
51    /// Human-readable message.
52    pub message: String,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    /// Optional 1-based line number.
55    pub line: Option<u32>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    /// Optional 1-based column number.
58    pub column: Option<u32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    /// Optional structured metadata for tooling.
61    pub metadata: Option<serde_json::Value>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65/// A validation report with a computed summary.
66pub struct ValidationReport {
67    /// Whether validation passed for the selected strictness.
68    pub valid: bool,
69
70    /// All issues found (errors + warnings + info).
71    pub issues: Vec<ValidationIssue>,
72
73    /// Counts grouped by severity.
74    pub summary: ValidationSummary,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78/// Aggregated counts for a validation run.
79pub struct ValidationSummary {
80    /// Number of `ERROR` issues.
81    pub errors: u32,
82    /// Number of `WARNING` issues.
83    pub warnings: u32,
84    /// Number of `INFO` issues.
85    pub info: u32,
86}
87
88impl ValidationReport {
89    /// Construct a report and compute summary + `valid`.
90    ///
91    /// When `strict` is `true`, warnings are treated as failures.
92    pub fn new(issues: Vec<ValidationIssue>, strict: bool) -> Self {
93        let mut errors = 0u32;
94        let mut warnings = 0u32;
95        let mut info = 0u32;
96        for i in &issues {
97            match i.level.as_str() {
98                LEVEL_ERROR => errors += 1,
99                LEVEL_WARNING => warnings += 1,
100                LEVEL_INFO => info += 1,
101                _ => {}
102            }
103        }
104        let valid = if strict {
105            errors == 0 && warnings == 0
106        } else {
107            errors == 0
108        };
109        Self {
110            valid,
111            issues,
112            summary: ValidationSummary {
113                errors,
114                warnings,
115                info,
116            },
117        }
118    }
119}
120
121/// Validate a spec markdown string and return a structured report.
122pub fn validate_spec_markdown(markdown: &str, strict: bool) -> ValidationReport {
123    let json = parse_spec_show_json("<spec>", markdown);
124
125    let mut r = report(strict);
126
127    if json.overview.trim().is_empty() {
128        r.push(error("purpose", "Purpose section cannot be empty"));
129    } else if json.overview.len() < MIN_PURPOSE_LENGTH {
130        r.push(warning(
131            "purpose",
132            "Purpose section is too brief (less than 50 characters)",
133        ));
134    }
135
136    if json.requirements.is_empty() {
137        r.push(error(
138            "requirements",
139            "Spec must have at least one requirement",
140        ));
141    }
142
143    for (idx, req) in json.requirements.iter().enumerate() {
144        let path = format!("requirements[{idx}]");
145        if req.text.trim().is_empty() {
146            r.push(error(&path, "Requirement text cannot be empty"));
147        }
148        if req.scenarios.is_empty() {
149            r.push(error(&path, "Requirement must have at least one scenario"));
150        }
151        for (sidx, sc) in req.scenarios.iter().enumerate() {
152            let sp = format!("{path}.scenarios[{sidx}]");
153            if sc.raw_text.trim().is_empty() {
154                r.push(error(&sp, "Scenario text cannot be empty"));
155            }
156        }
157    }
158
159    r.finish()
160}
161
162/// Validate a spec by id from `.ito/specs/<id>/spec.md`.
163pub fn validate_spec(ito_path: &Path, spec_id: &str, strict: bool) -> CoreResult<ValidationReport> {
164    let path = paths::spec_markdown_path(ito_path, spec_id);
165    let markdown = ito_common::io::read_to_string_std(&path)
166        .map_err(|e| CoreError::io(format!("reading spec {}", spec_id), e))?;
167    Ok(validate_spec_markdown(&markdown, strict))
168}
169
170/// Validate a change's delta specs by change id.
171pub fn validate_change(
172    change_repo: &impl DomainChangeRepository,
173    change_id: &str,
174    strict: bool,
175) -> CoreResult<ValidationReport> {
176    let files = read_change_delta_spec_files(change_repo, change_id)?;
177    if files.is_empty() {
178        let mut r = report(strict);
179        r.push(error("specs", "Change must have at least one delta"));
180        return Ok(r.finish());
181    }
182
183    let show = parse_change_show_json(change_id, &files);
184    let mut rep = report(strict);
185    if show.deltas.is_empty() {
186        rep.push(error("specs", "Change must have at least one delta"));
187        return Ok(rep.finish());
188    }
189
190    if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
191        rep.push(info(
192            "deltas",
193            "Consider splitting changes with more than 10 deltas",
194        ));
195    }
196
197    for (idx, d) in show.deltas.iter().enumerate() {
198        let base = format!("deltas[{idx}]");
199        if d.description.trim().is_empty() {
200            rep.push(error(&base, "Delta description cannot be empty"));
201        } else if d.description.trim().len() < 20 {
202            rep.push(warning(&base, "Delta description is too brief"));
203        }
204
205        if d.requirements.is_empty() {
206            rep.push(warning(&base, "Delta should include requirements"));
207        }
208
209        for (ridx, req) in d.requirements.iter().enumerate() {
210            let rp = format!("{base}.requirements[{ridx}]");
211            if req.text.trim().is_empty() {
212                rep.push(error(&rp, "Requirement text cannot be empty"));
213            }
214            let up = req.text.to_ascii_uppercase();
215            if !up.contains("SHALL") && !up.contains("MUST") {
216                rep.push(error(&rp, "Requirement must contain SHALL or MUST keyword"));
217            }
218            if req.scenarios.is_empty() {
219                rep.push(error(&rp, "Requirement must have at least one scenario"));
220            }
221        }
222    }
223
224    Ok(rep.finish())
225}
226
227#[derive(Debug, Clone)]
228/// A resolved module reference (directory + key paths).
229pub struct ResolvedModule {
230    /// 3-digit module id.
231    pub id: String,
232    /// Directory name under `.ito/modules/`.
233    pub full_name: String,
234    /// Full path to the module directory.
235    pub module_dir: PathBuf,
236    /// Full path to `module.md`.
237    pub module_md: PathBuf,
238}
239
240/// Resolve a module directory name from user input.
241///
242/// Input can be a full directory name (`NNN_slug`) or the numeric module id
243/// (`NNN`). Empty input returns `Ok(None)`.
244pub fn resolve_module(
245    module_repo: &impl DomainModuleRepository,
246    _ito_path: &Path,
247    input: &str,
248) -> CoreResult<Option<ResolvedModule>> {
249    let trimmed = input.trim();
250    if trimmed.is_empty() {
251        return Ok(None);
252    }
253
254    let module = module_repo.get(trimmed).into_core();
255    match module {
256        Ok(m) => {
257            let full_name = format!("{}_{}", m.id, m.name);
258            let module_dir = m.path;
259            let module_md = module_dir.join("module.md");
260            Ok(Some(ResolvedModule {
261                id: m.id,
262                full_name,
263                module_dir,
264                module_md,
265            }))
266        }
267        Err(_) => Ok(None),
268    }
269}
270
271/// Validate a module's `module.md` for minimal required sections.
272///
273/// Returns the resolved module directory name along with the report.
274pub fn validate_module(
275    module_repo: &impl DomainModuleRepository,
276    ito_path: &Path,
277    module_input: &str,
278    strict: bool,
279) -> CoreResult<(String, ValidationReport)> {
280    let resolved = resolve_module(module_repo, ito_path, module_input)?;
281    let Some(r) = resolved else {
282        let mut rep = report(strict);
283        rep.push(error("module", "Module not found"));
284        return Ok((module_input.to_string(), rep.finish()));
285    };
286
287    let mut rep = report(strict);
288    let md = match ito_common::io::read_to_string_std(&r.module_md) {
289        Ok(c) => c,
290        Err(_) => {
291            rep.push(error("file", "Module must have a Purpose section"));
292            return Ok((r.full_name, rep.finish()));
293        }
294    };
295
296    let purpose = extract_section(&md, "Purpose");
297    if purpose.trim().is_empty() {
298        rep.push(error("purpose", "Module must have a Purpose section"));
299    } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
300        rep.push(error(
301            "purpose",
302            "Module purpose must be at least 20 characters",
303        ));
304    }
305
306    let scope = extract_section(&md, "Scope");
307    if scope.trim().is_empty() {
308        rep.push(error(
309            "scope",
310            "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
311        ));
312    }
313
314    Ok((r.full_name, rep.finish()))
315}
316
317fn extract_section(markdown: &str, header: &str) -> String {
318    let mut in_section = false;
319    let mut out = String::new();
320    let normalized = markdown.replace('\r', "");
321    for raw in normalized.split('\n') {
322        let line = raw.trim_end();
323        if let Some(h) = line.strip_prefix("## ") {
324            let title = h.trim();
325            if title.eq_ignore_ascii_case(header) {
326                in_section = true;
327                continue;
328            }
329            if in_section {
330                break;
331            }
332        }
333        if in_section {
334            out.push_str(line);
335            out.push('\n');
336        }
337    }
338    out
339}
340
341/// Validate a change's tasks.md file and return any issues found.
342pub fn validate_tasks_file(ito_path: &Path, change_id: &str) -> CoreResult<Vec<ValidationIssue>> {
343    use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file, tasks_path};
344
345    let path = tasks_path(ito_path, change_id);
346    let report_path = format!("changes/{change_id}/tasks.md");
347
348    let contents = match ito_common::io::read_to_string(&path) {
349        Ok(c) => c,
350        Err(e) => {
351            return Ok(vec![error(
352                &report_path,
353                format!("Failed to read {report_path}: {e}"),
354            )]);
355        }
356    };
357
358    let parsed = parse_tasks_tracking_file(&contents);
359    let mut issues = Vec::new();
360    for d in &parsed.diagnostics {
361        let level = match d.level {
362            DiagnosticLevel::Error => LEVEL_ERROR,
363            DiagnosticLevel::Warning => LEVEL_WARNING,
364        };
365        issues.push(ValidationIssue {
366            path: report_path.clone(),
367            level: level.to_string(),
368            message: d.message.clone(),
369            line: d.line.map(|l| l as u32),
370            column: None,
371            metadata: None,
372        });
373    }
374    Ok(issues)
375}