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 if let Some(validator_id @ ValidatorId::DeltaSpecsV1) = cfg.validate_as {
385 let ctx = ArtifactValidatorContext {
388 ito_path,
389 change_id,
390 strict,
391 };
392 run_validator_for_artifact(
393 rep,
394 change_repo,
395 ctx,
396 artifact_id,
397 &schema_artifact.generates,
398 validator_id,
399 )?;
400 }
401 continue;
402 }
403
404 let Some(validator_id) = cfg.validate_as else {
405 continue;
406 };
407 let ctx = ArtifactValidatorContext {
408 ito_path,
409 change_id,
410 strict,
411 };
412 run_validator_for_artifact(
413 rep,
414 change_repo,
415 ctx,
416 artifact_id,
417 &schema_artifact.generates,
418 validator_id,
419 )?;
420 }
421
422 if let Some(tracking) = validation.tracking.as_ref() {
423 match tracking.source {
424 crate::templates::ValidationTrackingSourceYaml::ApplyTracks => {
425 let tracks_rel = resolved
426 .schema
427 .apply
428 .as_ref()
429 .and_then(|a| a.tracks.as_deref());
430
431 let Some(tracks_rel) = tracks_rel else {
432 if tracking.required {
433 rep.push(error(
434 "tracking",
435 "Schema tracking is required but schema apply.tracks is not set",
436 ));
437 }
438 return Ok(());
439 };
440
441 if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
442 rep.push(error(
443 "tracking",
444 format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
445 ));
446 return Ok(());
447 }
448
449 let report_path = format!("changes/{change_id}/{tracks_rel}");
450 let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
451
452 let present = abs_path.exists();
453 if tracking.required && !present {
454 rep.push(error(
455 "tracking",
456 format!("Missing required tracking file: {report_path}"),
457 ));
458 }
459 if !present {
460 return Ok(());
461 }
462
463 match tracking.validate_as {
464 ValidatorId::TasksTrackingV1 => {
465 rep.extend(validate_tasks_tracking_path(
466 &abs_path,
467 &report_path,
468 strict,
469 ));
470 }
471 ValidatorId::DeltaSpecsV1 => {
472 rep.push(error(
473 "schema.validation",
474 "Validator 'ito.delta-specs.v1' is not valid for tracking files",
475 ));
476 }
477 }
478 }
479 }
480 }
481
482 Ok(())
483}
484
485fn run_validator_for_artifact(
486 rep: &mut ReportBuilder,
487 change_repo: &impl DomainChangeRepository,
488 ctx: ArtifactValidatorContext<'_>,
489 artifact_id: &str,
490 generates: &str,
491 validator_id: ValidatorId,
492) -> CoreResult<()> {
493 match validator_id {
494 ValidatorId::DeltaSpecsV1 => {
495 validate_change_delta_specs(rep, change_repo, ctx.change_id)?;
496 }
497 ValidatorId::TasksTrackingV1 => {
498 use format_specs::TASKS_TRACKING_V1;
499
500 if generates.contains('*') {
501 rep.push(with_format_spec(
502 error(
503 format!("artifacts.{artifact_id}"),
504 format!(
505 "Validator '{}' requires a single file path; got pattern '{}'",
506 TASKS_TRACKING_V1.validator_id, generates
507 ),
508 ),
509 TASKS_TRACKING_V1,
510 ));
511 return Ok(());
512 }
513
514 let report_path = format!("changes/{}/{generates}", ctx.change_id);
515 let abs_path = paths::change_dir(ctx.ito_path, ctx.change_id).join(generates);
516 rep.extend(validate_tasks_tracking_path(
517 &abs_path,
518 &report_path,
519 ctx.strict,
520 ));
521 }
522 }
523 Ok(())
524}
525
526#[derive(Debug, Clone, Copy)]
527struct ArtifactValidatorContext<'a> {
528 ito_path: &'a Path,
529 change_id: &'a str,
530 strict: bool,
531}
532
533fn validate_tasks_tracking_path(
534 path: &Path,
535 report_path: &str,
536 strict: bool,
537) -> Vec<ValidationIssue> {
538 use format_specs::TASKS_TRACKING_V1;
539 use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file};
540
541 let contents = match ito_common::io::read_to_string(path) {
542 Ok(c) => c,
543 Err(e) => {
544 return vec![with_format_spec(
545 error(report_path, format!("Failed to read {report_path}: {e}")),
546 TASKS_TRACKING_V1,
547 )];
548 }
549 };
550
551 let parsed = parse_tasks_tracking_file(&contents);
552 let mut issues = Vec::new();
553
554 if parsed.tasks.is_empty() {
555 let msg = "Tracking file contains no recognizable tasks";
556 let i = if strict {
557 error(report_path, msg)
558 } else {
559 warning(report_path, msg)
560 };
561 issues.push(with_format_spec(i, TASKS_TRACKING_V1));
562 }
563 for d in &parsed.diagnostics {
564 let level = match d.level {
565 DiagnosticLevel::Error => LEVEL_ERROR,
566 DiagnosticLevel::Warning => LEVEL_WARNING,
567 };
568 issues.push(with_format_spec(
569 ValidationIssue {
570 path: report_path.to_string(),
571 level: level.to_string(),
572 message: d.message.clone(),
573 line: d.line.map(|l| l as u32),
574 column: None,
575 metadata: None,
576 },
577 TASKS_TRACKING_V1,
578 ));
579 }
580 issues
581}
582
583fn validate_change_delta_specs(
584 rep: &mut ReportBuilder,
585 change_repo: &impl DomainChangeRepository,
586 change_id: &str,
587) -> CoreResult<()> {
588 use format_specs::DELTA_SPECS_V1;
589
590 let files = read_change_delta_spec_files(change_repo, change_id)?;
591 if files.is_empty() {
592 rep.push(with_format_spec(
593 error("specs", "Change must have at least one delta"),
594 DELTA_SPECS_V1,
595 ));
596 return Ok(());
597 }
598
599 let show = parse_change_show_json(change_id, &files);
600 if show.deltas.is_empty() {
601 rep.push(with_format_spec(
602 error("specs", "Change must have at least one delta"),
603 DELTA_SPECS_V1,
604 ));
605 return Ok(());
606 }
607
608 if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
609 rep.push(with_format_spec(
610 info(
611 "deltas",
612 "Consider splitting changes with more than 10 deltas",
613 ),
614 DELTA_SPECS_V1,
615 ));
616 }
617
618 for (idx, d) in show.deltas.iter().enumerate() {
619 let base = format!("deltas[{idx}]");
620 if d.description.trim().is_empty() {
621 rep.push(with_format_spec(
622 error(&base, "Delta description cannot be empty"),
623 DELTA_SPECS_V1,
624 ));
625 } else if d.description.trim().len() < 20 {
626 rep.push(with_format_spec(
627 warning(&base, "Delta description is too brief"),
628 DELTA_SPECS_V1,
629 ));
630 }
631
632 if d.requirements.is_empty() {
633 rep.push(with_format_spec(
634 warning(&base, "Delta should include requirements"),
635 DELTA_SPECS_V1,
636 ));
637 }
638
639 for (ridx, req) in d.requirements.iter().enumerate() {
640 let rp = format!("{base}.requirements[{ridx}]");
641 if req.text.trim().is_empty() {
642 rep.push(with_format_spec(
643 error(&rp, "Requirement text cannot be empty"),
644 DELTA_SPECS_V1,
645 ));
646 }
647 let up = req.text.to_ascii_uppercase();
648 if !up.contains("SHALL") && !up.contains("MUST") {
649 rep.push(with_format_spec(
650 error(&rp, "Requirement must contain SHALL or MUST keyword"),
651 DELTA_SPECS_V1,
652 ));
653 }
654 if req.scenarios.is_empty() {
655 rep.push(with_format_spec(
656 error(&rp, "Requirement must have at least one scenario"),
657 DELTA_SPECS_V1,
658 ));
659 }
660 }
661 }
662 Ok(())
663}
664
665#[derive(Debug, Clone)]
666pub struct ResolvedModule {
668 pub id: String,
670 pub full_name: String,
672 pub module_dir: PathBuf,
674 pub module_md: PathBuf,
676}
677
678pub fn resolve_module(
683 module_repo: &impl DomainModuleRepository,
684 _ito_path: &Path,
685 input: &str,
686) -> CoreResult<Option<ResolvedModule>> {
687 let trimmed = input.trim();
688 if trimmed.is_empty() {
689 return Ok(None);
690 }
691
692 let module = module_repo.get(trimmed).into_core();
693 match module {
694 Ok(m) => {
695 let full_name = format!("{}_{}", m.id, m.name);
696 let module_dir = m.path;
697 let module_md = module_dir.join("module.md");
698 Ok(Some(ResolvedModule {
699 id: m.id,
700 full_name,
701 module_dir,
702 module_md,
703 }))
704 }
705 Err(_) => Ok(None),
706 }
707}
708
709pub fn validate_module(
713 module_repo: &impl DomainModuleRepository,
714 ito_path: &Path,
715 module_input: &str,
716 strict: bool,
717) -> CoreResult<(String, ValidationReport)> {
718 let resolved = resolve_module(module_repo, ito_path, module_input)?;
719 let Some(r) = resolved else {
720 let mut rep = report(strict);
721 rep.push(error("module", "Module not found"));
722 return Ok((module_input.to_string(), rep.finish()));
723 };
724
725 let mut rep = report(strict);
726 let md = match ito_common::io::read_to_string_std(&r.module_md) {
727 Ok(c) => c,
728 Err(_) => {
729 rep.push(error("file", "Module must have a Purpose section"));
730 return Ok((r.full_name, rep.finish()));
731 }
732 };
733
734 let purpose = extract_section(&md, "Purpose");
735 if purpose.trim().is_empty() {
736 rep.push(error("purpose", "Module must have a Purpose section"));
737 } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
738 rep.push(error(
739 "purpose",
740 "Module purpose must be at least 20 characters",
741 ));
742 }
743
744 let scope = extract_section(&md, "Scope");
745 if scope.trim().is_empty() {
746 rep.push(error(
747 "scope",
748 "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
749 ));
750 }
751
752 Ok((r.full_name, rep.finish()))
753}
754
755fn extract_section(markdown: &str, header: &str) -> String {
756 let mut in_section = false;
757 let mut out = String::new();
758 let normalized = markdown.replace('\r', "");
759 for raw in normalized.split('\n') {
760 let line = raw.trim_end();
761 if let Some(h) = line.strip_prefix("## ") {
762 let title = h.trim();
763 if title.eq_ignore_ascii_case(header) {
764 in_section = true;
765 continue;
766 }
767 if in_section {
768 break;
769 }
770 }
771 if in_section {
772 out.push_str(line);
773 out.push('\n');
774 }
775 }
776 out
777}
778
779pub fn validate_tasks_file(
781 ito_path: &Path,
782 change_id: &str,
783 strict: bool,
784) -> CoreResult<Vec<ValidationIssue>> {
785 use crate::templates::{load_schema_validation, read_change_schema, resolve_schema};
786 use ito_domain::tasks::tasks_path_checked;
787
788 if tasks_path_checked(ito_path, change_id).is_none() {
790 return Ok(vec![error(
791 "tracking",
792 format!("invalid change id path segment: \"{change_id}\""),
793 )]);
794 }
795
796 let schema_name = read_change_schema(ito_path, change_id);
797 let mut ctx = ConfigContext::from_process_env();
798 ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
799
800 let mut issues: Vec<ValidationIssue> = Vec::new();
801
802 let mut tracking_file = "tasks.md".to_string();
803 let resolved = match resolve_schema(Some(&schema_name), &ctx) {
804 Ok(r) => Some(r),
805 Err(e) => {
806 issues.push(error(
807 "schema",
808 format!("Failed to resolve schema '{schema_name}': {e}"),
809 ));
810 None
811 }
812 };
813
814 if let Some(resolved) = resolved.as_ref() {
815 if let Ok(Some(validation)) = load_schema_validation(resolved)
818 && let Some(tracking) = validation.tracking.as_ref()
819 && tracking.validate_as != ValidatorId::TasksTrackingV1
820 {
821 issues.push(error(
822 "tracking",
823 format!(
824 "Schema tracking validator '{}' is not valid for tasks tracking files",
825 tracking.validate_as.as_str()
826 ),
827 ));
828 return Ok(issues);
829 }
830
831 if let Some(tracks) = resolved
832 .schema
833 .apply
834 .as_ref()
835 .and_then(|a| a.tracks.as_deref())
836 {
837 tracking_file = tracks.to_string();
838 }
839 }
840
841 if !ito_domain::tasks::is_safe_tracking_filename(&tracking_file) {
842 issues.push(error(
843 "tracking",
844 format!("Invalid tracking file path in apply.tracks: '{tracking_file}'"),
845 ));
846 return Ok(issues);
847 }
848
849 let path = paths::change_dir(ito_path, change_id).join(&tracking_file);
850 let report_path = format!("changes/{change_id}/{tracking_file}");
851 issues.extend(validate_tasks_tracking_path(&path, &report_path, strict));
852 Ok(issues)
853}