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 crate::templates::{
19    ResolvedSchema, ValidationLevelYaml, ValidationYaml, ValidatorId, artifact_done,
20    load_schema_validation, read_change_schema, resolve_schema,
21};
22use ito_config::ConfigContext;
23use ito_domain::changes::ChangeRepository as DomainChangeRepository;
24use ito_domain::modules::ModuleRepository as DomainModuleRepository;
25
26mod format_specs;
27mod issue;
28mod repo_integrity;
29mod report;
30
31pub(crate) use issue::with_format_spec;
32pub use issue::{error, info, issue, warning, with_line, with_loc, with_metadata};
33pub use repo_integrity::validate_change_dirs_repo_integrity;
34pub use report::{ReportBuilder, report};
35
36/// Severity level for a [`ValidationIssue`].
37pub type ValidationLevel = &'static str;
38
39/// Validation issue is an error (always fails validation).
40pub const LEVEL_ERROR: ValidationLevel = "ERROR";
41/// Validation issue is a warning (fails validation in strict mode).
42pub const LEVEL_WARNING: ValidationLevel = "WARNING";
43/// Validation issue is informational (never fails validation).
44pub const LEVEL_INFO: ValidationLevel = "INFO";
45
46// Thresholds: match TS defaults.
47const MIN_PURPOSE_LENGTH: usize = 50;
48const MIN_MODULE_PURPOSE_LENGTH: usize = 20;
49const MAX_DELTAS_PER_CHANGE: usize = 10;
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52/// One validation finding.
53pub struct ValidationIssue {
54    /// Issue severity.
55    pub level: String,
56    /// Logical path within the validated artifact (or a filename).
57    pub path: String,
58    /// Human-readable message.
59    pub message: String,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    /// Optional 1-based line number.
62    pub line: Option<u32>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    /// Optional 1-based column number.
65    pub column: Option<u32>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    /// Optional structured metadata for tooling.
68    pub metadata: Option<serde_json::Value>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72/// A validation report with a computed summary.
73pub struct ValidationReport {
74    /// Whether validation passed for the selected strictness.
75    pub valid: bool,
76
77    /// All issues found (errors + warnings + info).
78    pub issues: Vec<ValidationIssue>,
79
80    /// Counts grouped by severity.
81    pub summary: ValidationSummary,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85/// Aggregated counts for a validation run.
86pub struct ValidationSummary {
87    /// Number of `ERROR` issues.
88    pub errors: u32,
89    /// Number of `WARNING` issues.
90    pub warnings: u32,
91    /// Number of `INFO` issues.
92    pub info: u32,
93}
94
95impl ValidationReport {
96    /// Construct a report and compute summary + `valid`.
97    ///
98    /// When `strict` is `true`, warnings are treated as failures.
99    pub fn new(issues: Vec<ValidationIssue>, strict: bool) -> Self {
100        let mut errors = 0u32;
101        let mut warnings = 0u32;
102        let mut info = 0u32;
103        for i in &issues {
104            match i.level.as_str() {
105                LEVEL_ERROR => errors += 1,
106                LEVEL_WARNING => warnings += 1,
107                LEVEL_INFO => info += 1,
108                _ => {}
109            }
110        }
111        let valid = if strict {
112            errors == 0 && warnings == 0
113        } else {
114            errors == 0
115        };
116        Self {
117            valid,
118            issues,
119            summary: ValidationSummary {
120                errors,
121                warnings,
122                info,
123            },
124        }
125    }
126}
127
128/// Validate a spec markdown string and return a structured report.
129pub fn validate_spec_markdown(markdown: &str, strict: bool) -> ValidationReport {
130    let json = parse_spec_show_json("<spec>", markdown);
131
132    let mut r = report(strict);
133
134    if json.overview.trim().is_empty() {
135        r.push(error("purpose", "Purpose section cannot be empty"));
136    } else if json.overview.len() < MIN_PURPOSE_LENGTH {
137        r.push(warning(
138            "purpose",
139            "Purpose section is too brief (less than 50 characters)",
140        ));
141    }
142
143    if json.requirements.is_empty() {
144        r.push(error(
145            "requirements",
146            "Spec must have at least one requirement",
147        ));
148    }
149
150    for (idx, req) in json.requirements.iter().enumerate() {
151        let path = format!("requirements[{idx}]");
152        if req.text.trim().is_empty() {
153            r.push(error(&path, "Requirement text cannot be empty"));
154        }
155        if req.scenarios.is_empty() {
156            r.push(error(&path, "Requirement must have at least one scenario"));
157        }
158        for (sidx, sc) in req.scenarios.iter().enumerate() {
159            let sp = format!("{path}.scenarios[{sidx}]");
160            if sc.raw_text.trim().is_empty() {
161                r.push(error(&sp, "Scenario text cannot be empty"));
162            }
163        }
164    }
165
166    r.finish()
167}
168
169/// Validate a spec by id from `.ito/specs/<id>/spec.md`.
170pub fn validate_spec(ito_path: &Path, spec_id: &str, strict: bool) -> CoreResult<ValidationReport> {
171    let path = paths::spec_markdown_path(ito_path, spec_id);
172    let markdown = ito_common::io::read_to_string_std(&path)
173        .map_err(|e| CoreError::io(format!("reading spec {}", spec_id), e))?;
174    Ok(validate_spec_markdown(&markdown, strict))
175}
176
177/// Validate a change's delta specs by change id.
178pub fn validate_change(
179    change_repo: &impl DomainChangeRepository,
180    ito_path: &Path,
181    change_id: &str,
182    strict: bool,
183) -> CoreResult<ValidationReport> {
184    let mut rep = report(strict);
185
186    let (ctx, schema_name) = resolve_validation_context(ito_path, change_id);
187
188    let resolved = match resolve_schema(Some(&schema_name), &ctx) {
189        Ok(s) => {
190            rep.push(info(
191                "schema",
192                format!(
193                    "Resolved schema '{}' from {}",
194                    s.schema.name,
195                    s.source.as_str()
196                ),
197            ));
198            Some(s)
199        }
200        Err(e) => {
201            rep.push(error(
202                "schema",
203                format!("Failed to resolve schema '{schema_name}': {e}"),
204            ));
205            None
206        }
207    };
208
209    if let Some(resolved) = &resolved {
210        match load_schema_validation(resolved) {
211            Ok(Some(validation)) => {
212                rep.push(info("schema.validation", "Using schema validation.yaml"));
213                validate_change_against_schema_validation(
214                    &mut rep,
215                    change_repo,
216                    ito_path,
217                    change_id,
218                    resolved,
219                    &validation,
220                    strict,
221                )?;
222                return Ok(rep.finish());
223            }
224            Ok(None) => {}
225            Err(e) => {
226                rep.push(error(
227                    "schema.validation",
228                    format!("Failed to load schema validation.yaml: {e}"),
229                ));
230                return Ok(rep.finish());
231            }
232        }
233
234        if is_legacy_delta_schema(&resolved.schema.name) {
235            validate_change_delta_specs(&mut rep, change_repo, change_id)?;
236
237            let tracks_rel = resolved
238                .schema
239                .apply
240                .as_ref()
241                .and_then(|a| a.tracks.as_deref())
242                .unwrap_or("tasks.md");
243
244            if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
245                rep.push(error(
246                    "tracking",
247                    format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
248                ));
249                return Ok(rep.finish());
250            }
251
252            let report_path = format!("changes/{change_id}/{tracks_rel}");
253            let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
254            rep.extend(validate_tasks_tracking_path(
255                &abs_path,
256                &report_path,
257                strict,
258            ));
259            return Ok(rep.finish());
260        }
261
262        rep.push(info(
263            "schema.validation",
264            "Schema has no validation.yaml; manual validation required",
265        ));
266        validate_apply_required_artifacts(&mut rep, ito_path, change_id, resolved);
267        return Ok(rep.finish());
268    }
269
270    validate_change_delta_specs(&mut rep, change_repo, change_id)?;
271    Ok(rep.finish())
272}
273
274/// Returns true for built-in schemas that predate schema-driven `validation.yaml`.
275fn is_legacy_delta_schema(schema_name: &str) -> bool {
276    schema_name == "spec-driven" || schema_name == "tdd"
277}
278
279fn schema_artifact_ids(resolved: &ResolvedSchema) -> Vec<String> {
280    let mut ids = Vec::new();
281    for a in &resolved.schema.artifacts {
282        ids.push(a.id.clone());
283    }
284    ids
285}
286
287fn validate_apply_required_artifacts(
288    rep: &mut ReportBuilder,
289    ito_path: &Path,
290    change_id: &str,
291    resolved: &ResolvedSchema,
292) {
293    let change_dir = paths::change_dir(ito_path, change_id);
294    if !change_dir.exists() {
295        rep.push(error(
296            "change",
297            format!("Change directory not found: changes/{change_id}"),
298        ));
299        return;
300    }
301
302    let required_ids: Vec<String> = match resolved.schema.apply.as_ref() {
303        Some(apply) => apply
304            .requires
305            .clone()
306            .unwrap_or_else(|| schema_artifact_ids(resolved)),
307        None => schema_artifact_ids(resolved),
308    };
309
310    for id in required_ids {
311        let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
312            rep.push(error(
313                "schema.validation",
314                format!("Schema apply.requires references unknown artifact id '{id}'"),
315            ));
316            continue;
317        };
318        if artifact_done(&change_dir, &a.generates) {
319            continue;
320        }
321        rep.push(warning(
322            format!("artifacts.{id}"),
323            format!(
324                "Apply-required artifact '{id}' is missing (expected output: {})",
325                a.generates
326            ),
327        ));
328    }
329}
330
331fn resolve_validation_context(ito_path: &Path, change_id: &str) -> (ConfigContext, String) {
332    let schema_name = read_change_schema(ito_path, change_id);
333
334    let mut ctx = ConfigContext::from_process_env();
335    ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
336
337    (ctx, schema_name)
338}
339
340fn validate_change_against_schema_validation(
341    rep: &mut ReportBuilder,
342    change_repo: &impl DomainChangeRepository,
343    ito_path: &Path,
344    change_id: &str,
345    resolved: &ResolvedSchema,
346    validation: &ValidationYaml,
347    strict: bool,
348) -> CoreResult<()> {
349    let change_dir = paths::change_dir(ito_path, change_id);
350
351    let missing_level = validation
352        .defaults
353        .missing_required_artifact_level
354        .unwrap_or(ValidationLevelYaml::Warning)
355        .as_level_str();
356
357    for (artifact_id, cfg) in &validation.artifacts {
358        let Some(schema_artifact) = resolved
359            .schema
360            .artifacts
361            .iter()
362            .find(|a| a.id == *artifact_id)
363        else {
364            rep.push(error(
365                "schema.validation",
366                format!("validation.yaml references unknown artifact id '{artifact_id}'"),
367            ));
368            continue;
369        };
370
371        let present = artifact_done(&change_dir, &schema_artifact.generates);
372        if cfg.required && !present {
373            rep.push(issue(
374                missing_level,
375                format!("artifacts.{artifact_id}"),
376                format!(
377                    "Missing required artifact '{artifact_id}' (expected output: {})",
378                    schema_artifact.generates
379                ),
380            ));
381        }
382
383        if !present {
384            continue;
385        }
386
387        let Some(validator_id) = cfg.validate_as else {
388            continue;
389        };
390        let ctx = ArtifactValidatorContext {
391            ito_path,
392            change_id,
393            strict,
394        };
395        run_validator_for_artifact(
396            rep,
397            change_repo,
398            ctx,
399            artifact_id,
400            &schema_artifact.generates,
401            validator_id,
402        )?;
403    }
404
405    if let Some(tracking) = validation.tracking.as_ref() {
406        match tracking.source {
407            crate::templates::ValidationTrackingSourceYaml::ApplyTracks => {
408                let tracks_rel = resolved
409                    .schema
410                    .apply
411                    .as_ref()
412                    .and_then(|a| a.tracks.as_deref());
413
414                let Some(tracks_rel) = tracks_rel else {
415                    if tracking.required {
416                        rep.push(error(
417                            "tracking",
418                            "Schema tracking is required but schema apply.tracks is not set",
419                        ));
420                    }
421                    return Ok(());
422                };
423
424                if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
425                    rep.push(error(
426                        "tracking",
427                        format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
428                    ));
429                    return Ok(());
430                }
431
432                let report_path = format!("changes/{change_id}/{tracks_rel}");
433                let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
434
435                let present = abs_path.exists();
436                if tracking.required && !present {
437                    rep.push(error(
438                        "tracking",
439                        format!("Missing required tracking file: {report_path}"),
440                    ));
441                }
442                if !present {
443                    return Ok(());
444                }
445
446                match tracking.validate_as {
447                    ValidatorId::TasksTrackingV1 => {
448                        rep.extend(validate_tasks_tracking_path(
449                            &abs_path,
450                            &report_path,
451                            strict,
452                        ));
453                    }
454                    ValidatorId::DeltaSpecsV1 => {
455                        rep.push(error(
456                            "schema.validation",
457                            "Validator 'ito.delta-specs.v1' is not valid for tracking files",
458                        ));
459                    }
460                }
461            }
462        }
463    }
464
465    Ok(())
466}
467
468fn run_validator_for_artifact(
469    rep: &mut ReportBuilder,
470    change_repo: &impl DomainChangeRepository,
471    ctx: ArtifactValidatorContext<'_>,
472    artifact_id: &str,
473    generates: &str,
474    validator_id: ValidatorId,
475) -> CoreResult<()> {
476    match validator_id {
477        ValidatorId::DeltaSpecsV1 => {
478            validate_change_delta_specs(rep, change_repo, ctx.change_id)?;
479        }
480        ValidatorId::TasksTrackingV1 => {
481            use format_specs::TASKS_TRACKING_V1;
482
483            if generates.contains('*') {
484                rep.push(with_format_spec(
485                    error(
486                        format!("artifacts.{artifact_id}"),
487                        format!(
488                            "Validator '{}' requires a single file path; got pattern '{}'",
489                            TASKS_TRACKING_V1.validator_id, generates
490                        ),
491                    ),
492                    TASKS_TRACKING_V1,
493                ));
494                return Ok(());
495            }
496
497            let report_path = format!("changes/{}/{generates}", ctx.change_id);
498            let abs_path = paths::change_dir(ctx.ito_path, ctx.change_id).join(generates);
499            rep.extend(validate_tasks_tracking_path(
500                &abs_path,
501                &report_path,
502                ctx.strict,
503            ));
504        }
505    }
506    Ok(())
507}
508
509#[derive(Debug, Clone, Copy)]
510struct ArtifactValidatorContext<'a> {
511    ito_path: &'a Path,
512    change_id: &'a str,
513    strict: bool,
514}
515
516fn validate_tasks_tracking_path(
517    path: &Path,
518    report_path: &str,
519    strict: bool,
520) -> Vec<ValidationIssue> {
521    use format_specs::TASKS_TRACKING_V1;
522    use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file};
523
524    let contents = match ito_common::io::read_to_string(path) {
525        Ok(c) => c,
526        Err(e) => {
527            return vec![with_format_spec(
528                error(report_path, format!("Failed to read {report_path}: {e}")),
529                TASKS_TRACKING_V1,
530            )];
531        }
532    };
533
534    let parsed = parse_tasks_tracking_file(&contents);
535    let mut issues = Vec::new();
536
537    if parsed.tasks.is_empty() {
538        let msg = "Tracking file contains no recognizable tasks";
539        let i = if strict {
540            error(report_path, msg)
541        } else {
542            warning(report_path, msg)
543        };
544        issues.push(with_format_spec(i, TASKS_TRACKING_V1));
545    }
546    for d in &parsed.diagnostics {
547        let level = match d.level {
548            DiagnosticLevel::Error => LEVEL_ERROR,
549            DiagnosticLevel::Warning => LEVEL_WARNING,
550        };
551        issues.push(with_format_spec(
552            ValidationIssue {
553                path: report_path.to_string(),
554                level: level.to_string(),
555                message: d.message.clone(),
556                line: d.line.map(|l| l as u32),
557                column: None,
558                metadata: None,
559            },
560            TASKS_TRACKING_V1,
561        ));
562    }
563    issues
564}
565
566fn validate_change_delta_specs(
567    rep: &mut ReportBuilder,
568    change_repo: &impl DomainChangeRepository,
569    change_id: &str,
570) -> CoreResult<()> {
571    use format_specs::DELTA_SPECS_V1;
572
573    let files = read_change_delta_spec_files(change_repo, change_id)?;
574    if files.is_empty() {
575        rep.push(with_format_spec(
576            error("specs", "Change must have at least one delta"),
577            DELTA_SPECS_V1,
578        ));
579        return Ok(());
580    }
581
582    let show = parse_change_show_json(change_id, &files);
583    if show.deltas.is_empty() {
584        rep.push(with_format_spec(
585            error("specs", "Change must have at least one delta"),
586            DELTA_SPECS_V1,
587        ));
588        return Ok(());
589    }
590
591    if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
592        rep.push(with_format_spec(
593            info(
594                "deltas",
595                "Consider splitting changes with more than 10 deltas",
596            ),
597            DELTA_SPECS_V1,
598        ));
599    }
600
601    for (idx, d) in show.deltas.iter().enumerate() {
602        let base = format!("deltas[{idx}]");
603        if d.description.trim().is_empty() {
604            rep.push(with_format_spec(
605                error(&base, "Delta description cannot be empty"),
606                DELTA_SPECS_V1,
607            ));
608        } else if d.description.trim().len() < 20 {
609            rep.push(with_format_spec(
610                warning(&base, "Delta description is too brief"),
611                DELTA_SPECS_V1,
612            ));
613        }
614
615        if d.requirements.is_empty() {
616            rep.push(with_format_spec(
617                warning(&base, "Delta should include requirements"),
618                DELTA_SPECS_V1,
619            ));
620        }
621
622        for (ridx, req) in d.requirements.iter().enumerate() {
623            let rp = format!("{base}.requirements[{ridx}]");
624            if req.text.trim().is_empty() {
625                rep.push(with_format_spec(
626                    error(&rp, "Requirement text cannot be empty"),
627                    DELTA_SPECS_V1,
628                ));
629            }
630            let up = req.text.to_ascii_uppercase();
631            if !up.contains("SHALL") && !up.contains("MUST") {
632                rep.push(with_format_spec(
633                    error(&rp, "Requirement must contain SHALL or MUST keyword"),
634                    DELTA_SPECS_V1,
635                ));
636            }
637            if req.scenarios.is_empty() {
638                rep.push(with_format_spec(
639                    error(&rp, "Requirement must have at least one scenario"),
640                    DELTA_SPECS_V1,
641                ));
642            }
643        }
644    }
645    Ok(())
646}
647
648#[derive(Debug, Clone)]
649/// A resolved module reference (directory + key paths).
650pub struct ResolvedModule {
651    /// 3-digit module id.
652    pub id: String,
653    /// Directory name under `.ito/modules/`.
654    pub full_name: String,
655    /// Full path to the module directory.
656    pub module_dir: PathBuf,
657    /// Full path to `module.md`.
658    pub module_md: PathBuf,
659}
660
661/// Resolve a module directory name from user input.
662///
663/// Input can be a full directory name (`NNN_slug`) or the numeric module id
664/// (`NNN`). Empty input returns `Ok(None)`.
665pub fn resolve_module(
666    module_repo: &impl DomainModuleRepository,
667    _ito_path: &Path,
668    input: &str,
669) -> CoreResult<Option<ResolvedModule>> {
670    let trimmed = input.trim();
671    if trimmed.is_empty() {
672        return Ok(None);
673    }
674
675    let module = module_repo.get(trimmed).into_core();
676    match module {
677        Ok(m) => {
678            let full_name = format!("{}_{}", m.id, m.name);
679            let module_dir = m.path;
680            let module_md = module_dir.join("module.md");
681            Ok(Some(ResolvedModule {
682                id: m.id,
683                full_name,
684                module_dir,
685                module_md,
686            }))
687        }
688        Err(_) => Ok(None),
689    }
690}
691
692/// Validate a module's `module.md` for minimal required sections.
693///
694/// Returns the resolved module directory name along with the report.
695pub fn validate_module(
696    module_repo: &impl DomainModuleRepository,
697    ito_path: &Path,
698    module_input: &str,
699    strict: bool,
700) -> CoreResult<(String, ValidationReport)> {
701    let resolved = resolve_module(module_repo, ito_path, module_input)?;
702    let Some(r) = resolved else {
703        let mut rep = report(strict);
704        rep.push(error("module", "Module not found"));
705        return Ok((module_input.to_string(), rep.finish()));
706    };
707
708    let mut rep = report(strict);
709    let md = match ito_common::io::read_to_string_std(&r.module_md) {
710        Ok(c) => c,
711        Err(_) => {
712            rep.push(error("file", "Module must have a Purpose section"));
713            return Ok((r.full_name, rep.finish()));
714        }
715    };
716
717    let purpose = extract_section(&md, "Purpose");
718    if purpose.trim().is_empty() {
719        rep.push(error("purpose", "Module must have a Purpose section"));
720    } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
721        rep.push(error(
722            "purpose",
723            "Module purpose must be at least 20 characters",
724        ));
725    }
726
727    let scope = extract_section(&md, "Scope");
728    if scope.trim().is_empty() {
729        rep.push(error(
730            "scope",
731            "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
732        ));
733    }
734
735    Ok((r.full_name, rep.finish()))
736}
737
738fn extract_section(markdown: &str, header: &str) -> String {
739    let mut in_section = false;
740    let mut out = String::new();
741    let normalized = markdown.replace('\r', "");
742    for raw in normalized.split('\n') {
743        let line = raw.trim_end();
744        if let Some(h) = line.strip_prefix("## ") {
745            let title = h.trim();
746            if title.eq_ignore_ascii_case(header) {
747                in_section = true;
748                continue;
749            }
750            if in_section {
751                break;
752            }
753        }
754        if in_section {
755            out.push_str(line);
756            out.push('\n');
757        }
758    }
759    out
760}
761
762/// Validate a change's tasks.md file and return any issues found.
763pub fn validate_tasks_file(
764    ito_path: &Path,
765    change_id: &str,
766    strict: bool,
767) -> CoreResult<Vec<ValidationIssue>> {
768    use ito_domain::tasks::tasks_path;
769
770    let path = tasks_path(ito_path, change_id);
771    let report_path = format!("changes/{change_id}/tasks.md");
772    Ok(validate_tasks_tracking_path(&path, &report_path, strict))
773}