1use 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
36pub type ValidationLevel = &'static str;
38
39pub const LEVEL_ERROR: ValidationLevel = "ERROR";
41pub const LEVEL_WARNING: ValidationLevel = "WARNING";
43pub const LEVEL_INFO: ValidationLevel = "INFO";
45
46const 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)]
52pub struct ValidationIssue {
54 pub level: String,
56 pub path: String,
58 pub message: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub line: Option<u32>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub column: Option<u32>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub metadata: Option<serde_json::Value>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct ValidationReport {
74 pub valid: bool,
76
77 pub issues: Vec<ValidationIssue>,
79
80 pub summary: ValidationSummary,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct ValidationSummary {
87 pub errors: u32,
89 pub warnings: u32,
91 pub info: u32,
93}
94
95impl ValidationReport {
96 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
128pub 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
169pub 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
177pub 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
274fn 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)]
649pub struct ResolvedModule {
651 pub id: String,
653 pub full_name: String,
655 pub module_dir: PathBuf,
657 pub module_md: PathBuf,
659}
660
661pub 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
692pub 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
762pub 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}