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 delta_rules;
27mod format_specs;
28mod issue;
29mod repo_integrity;
30mod report;
31mod rules_engine;
32mod tracking_rules;
33
34pub(crate) use issue::with_format_spec;
35pub use issue::{error, info, issue, warning, with_line, with_loc, with_metadata, with_rule_id};
36pub use repo_integrity::validate_change_dirs_repo_integrity;
37pub use report::{ReportBuilder, report};
38
39pub type ValidationLevel = &'static str;
41
42pub const LEVEL_ERROR: ValidationLevel = "ERROR";
44pub const LEVEL_WARNING: ValidationLevel = "WARNING";
46pub const LEVEL_INFO: ValidationLevel = "INFO";
48
49const MIN_PURPOSE_LENGTH: usize = 50;
51const MIN_MODULE_PURPOSE_LENGTH: usize = 20;
52const MAX_DELTAS_PER_CHANGE: usize = 10;
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55pub struct ValidationIssue {
57 pub level: String,
59 pub path: String,
61 pub message: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub line: Option<u32>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 pub column: Option<u32>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 pub rule_id: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub metadata: Option<serde_json::Value>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct ValidationReport {
80 pub valid: bool,
82
83 pub issues: Vec<ValidationIssue>,
85
86 pub summary: ValidationSummary,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91pub struct ValidationSummary {
93 pub errors: u32,
95 pub warnings: u32,
97 pub info: u32,
99}
100
101impl ValidationReport {
102 pub fn new(issues: Vec<ValidationIssue>, strict: bool) -> Self {
106 let mut errors = 0u32;
107 let mut warnings = 0u32;
108 let mut info = 0u32;
109 for i in &issues {
110 match i.level.as_str() {
111 LEVEL_ERROR => errors += 1,
112 LEVEL_WARNING => warnings += 1,
113 LEVEL_INFO => info += 1,
114 _ => {}
115 }
116 }
117 let valid = if strict {
118 errors == 0 && warnings == 0
119 } else {
120 errors == 0
121 };
122 Self {
123 valid,
124 issues,
125 summary: ValidationSummary {
126 errors,
127 warnings,
128 info,
129 },
130 }
131 }
132}
133
134pub fn validate_spec_markdown(markdown: &str, strict: bool) -> ValidationReport {
136 let json = parse_spec_show_json("<spec>", markdown);
137
138 let mut r = report(strict);
139
140 if json.overview.trim().is_empty() {
141 r.push(error("purpose", "Purpose section cannot be empty"));
142 } else if json.overview.len() < MIN_PURPOSE_LENGTH {
143 r.push(warning(
144 "purpose",
145 "Purpose section is too brief (less than 50 characters)",
146 ));
147 }
148
149 if json.requirements.is_empty() {
150 r.push(error(
151 "requirements",
152 "Spec must have at least one requirement",
153 ));
154 }
155
156 for (idx, req) in json.requirements.iter().enumerate() {
157 let path = format!("requirements[{idx}]");
158 if req.text.trim().is_empty() {
159 r.push(error(&path, "Requirement text cannot be empty"));
160 }
161 if req.scenarios.is_empty() {
162 r.push(error(&path, "Requirement must have at least one scenario"));
163 }
164 for (sidx, sc) in req.scenarios.iter().enumerate() {
165 let sp = format!("{path}.scenarios[{sidx}]");
166 if sc.raw_text.trim().is_empty() {
167 r.push(error(&sp, "Scenario text cannot be empty"));
168 }
169 }
170 }
171
172 r.finish()
173}
174
175pub fn validate_spec(ito_path: &Path, spec_id: &str, strict: bool) -> CoreResult<ValidationReport> {
177 let path = paths::spec_markdown_path(ito_path, spec_id);
178 let markdown = ito_common::io::read_to_string_std(&path)
179 .map_err(|e| CoreError::io(format!("reading spec {}", spec_id), e))?;
180 Ok(validate_spec_markdown(&markdown, strict))
181}
182
183pub fn validate_change(
186 change_repo: &(impl DomainChangeRepository + ?Sized),
187 ito_path: &Path,
188 change_id: &str,
189 strict: bool,
190) -> CoreResult<ValidationReport> {
191 let mut rep = report(strict);
192
193 let (ctx, schema_name) = resolve_validation_context(ito_path, change_id);
194
195 let resolved = match resolve_schema(Some(&schema_name), &ctx) {
196 Ok(s) => {
197 rep.push(info(
198 "schema",
199 format!(
200 "Resolved schema '{}' from {}",
201 s.schema.name,
202 s.source.as_str()
203 ),
204 ));
205 Some(s)
206 }
207 Err(e) => {
208 rep.push(error(
209 "schema",
210 format!("Failed to resolve schema '{schema_name}': {e}"),
211 ));
212 None
213 }
214 };
215
216 if let Some(resolved) = &resolved {
217 match load_schema_validation(resolved) {
218 Ok(Some(validation)) => {
219 rep.push(info("schema.validation", "Using schema validation.yaml"));
220 validate_change_against_schema_validation(
221 &mut rep,
222 change_repo,
223 ito_path,
224 change_id,
225 resolved,
226 &validation,
227 strict,
228 )?;
229 return Ok(rep.finish());
230 }
231 Ok(None) => {}
232 Err(e) => {
233 rep.push(error(
234 "schema.validation",
235 format!("Failed to load schema validation.yaml: {e}"),
236 ));
237 return Ok(rep.finish());
238 }
239 }
240
241 if is_legacy_delta_schema(&resolved.schema.name) {
242 validate_change_delta_specs(&mut rep, change_repo, change_id, strict)?;
243
244 let tracks_rel = resolved
245 .schema
246 .apply
247 .as_ref()
248 .and_then(|a| a.tracks.as_deref())
249 .unwrap_or("tasks.md");
250
251 if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
252 rep.push(error(
253 "tracking",
254 format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
255 ));
256 return Ok(rep.finish());
257 }
258
259 let report_path = format!("changes/{change_id}/{tracks_rel}");
260 let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
261 rep.extend(validate_tasks_tracking_path(
262 &abs_path,
263 &report_path,
264 strict,
265 ));
266 return Ok(rep.finish());
267 }
268
269 rep.push(info(
270 "schema.validation",
271 "Schema has no validation.yaml; manual validation required",
272 ));
273 validate_apply_required_artifacts(&mut rep, ito_path, change_id, resolved);
274 return Ok(rep.finish());
275 }
276
277 validate_change_delta_specs(&mut rep, change_repo, change_id, strict)?;
278 Ok(rep.finish())
279}
280
281fn is_legacy_delta_schema(schema_name: &str) -> bool {
283 schema_name == "spec-driven" || schema_name == "tdd"
284}
285
286fn schema_artifact_ids(resolved: &ResolvedSchema) -> Vec<String> {
287 let mut ids = Vec::new();
288 for a in &resolved.schema.artifacts {
289 ids.push(a.id.clone());
290 }
291 ids
292}
293
294fn validate_apply_required_artifacts(
295 rep: &mut ReportBuilder,
296 ito_path: &Path,
297 change_id: &str,
298 resolved: &ResolvedSchema,
299) {
300 let change_dir = paths::change_dir(ito_path, change_id);
301 if !change_dir.exists() {
302 rep.push(error(
303 "change",
304 format!("Change directory not found: changes/{change_id}"),
305 ));
306 return;
307 }
308
309 let required_ids: Vec<String> = match resolved.schema.apply.as_ref() {
310 Some(apply) => apply
311 .requires
312 .clone()
313 .unwrap_or_else(|| schema_artifact_ids(resolved)),
314 None => schema_artifact_ids(resolved),
315 };
316
317 for id in required_ids {
318 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
319 rep.push(error(
320 "schema.validation",
321 format!("Schema apply.requires references unknown artifact id '{id}'"),
322 ));
323 continue;
324 };
325 if artifact_done(&change_dir, &a.generates) {
326 continue;
327 }
328 rep.push(warning(
329 format!("artifacts.{id}"),
330 format!(
331 "Apply-required artifact '{id}' is missing (expected output: {})",
332 a.generates
333 ),
334 ));
335 }
336}
337
338fn resolve_validation_context(ito_path: &Path, change_id: &str) -> (ConfigContext, String) {
339 let schema_name = read_change_schema(ito_path, change_id);
340
341 let mut ctx = ConfigContext::from_process_env();
342 ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
343
344 (ctx, schema_name)
345}
346
347fn validate_change_against_schema_validation(
348 rep: &mut ReportBuilder,
349 change_repo: &(impl DomainChangeRepository + ?Sized),
350 ito_path: &Path,
351 change_id: &str,
352 resolved: &ResolvedSchema,
353 validation: &ValidationYaml,
354 strict: bool,
355) -> CoreResult<()> {
356 let change_dir = paths::change_dir(ito_path, change_id);
357
358 let missing_level = validation
359 .defaults
360 .missing_required_artifact_level
361 .unwrap_or(ValidationLevelYaml::Warning)
362 .as_level_str();
363
364 for (artifact_id, cfg) in &validation.artifacts {
365 let Some(schema_artifact) = resolved
366 .schema
367 .artifacts
368 .iter()
369 .find(|a| a.id == *artifact_id)
370 else {
371 rep.push(error(
372 "schema.validation",
373 format!("validation.yaml references unknown artifact id '{artifact_id}'"),
374 ));
375 continue;
376 };
377
378 let present = artifact_done(&change_dir, &schema_artifact.generates);
379 if cfg.required && !present {
380 rep.push(issue(
381 missing_level,
382 format!("artifacts.{artifact_id}"),
383 format!(
384 "Missing required artifact '{artifact_id}' (expected output: {})",
385 schema_artifact.generates
386 ),
387 ));
388 }
389
390 if !present {
391 if let Some(validator_id @ ValidatorId::DeltaSpecsV1) = cfg.validate_as {
392 let ctx = ArtifactValidatorContext {
395 ito_path,
396 change_id,
397 strict,
398 };
399 run_validator_for_artifact(
400 rep,
401 change_repo,
402 ctx,
403 artifact_id,
404 &schema_artifact.generates,
405 validator_id,
406 )?;
407 rules_engine::run_artifact_rules(
408 rep,
409 change_repo,
410 ctx,
411 validator_id,
412 artifact_id,
413 cfg.rules.as_ref(),
414 )?;
415 }
416 continue;
417 }
418
419 let Some(validator_id) = cfg.validate_as else {
420 continue;
421 };
422 let ctx = ArtifactValidatorContext {
423 ito_path,
424 change_id,
425 strict,
426 };
427 run_validator_for_artifact(
428 rep,
429 change_repo,
430 ctx,
431 artifact_id,
432 &schema_artifact.generates,
433 validator_id,
434 )?;
435 rules_engine::run_artifact_rules(
436 rep,
437 change_repo,
438 ctx,
439 validator_id,
440 artifact_id,
441 cfg.rules.as_ref(),
442 )?;
443 }
444
445 if let Some(proposal) = validation.proposal.as_ref() {
446 let report_path = format!("changes/{change_id}/proposal.md");
447 let abs_path = change_dir.join("proposal.md");
448 let present = abs_path.exists();
449
450 if proposal.required && !present {
451 rep.push(issue(
452 missing_level,
453 "proposal",
454 format!("Missing required proposal artifact: {report_path}"),
455 ));
456 }
457
458 if present && let Some(validator_id) = proposal.validate_as {
459 let ctx = ArtifactValidatorContext {
460 ito_path,
461 change_id,
462 strict,
463 };
464 match validator_id {
465 ValidatorId::DeltaSpecsV1 => {
466 rules_engine::run_proposal_rules(
467 rep,
468 change_repo,
469 ctx,
470 validator_id,
471 proposal.rules.as_ref(),
472 )?;
473 }
474 ValidatorId::TasksTrackingV1 => {
475 rep.push(error(
476 "schema.validation",
477 "Validator 'ito.tasks-tracking.v1' is not valid for proposal artifacts",
478 ));
479 }
480 }
481 }
482 }
483
484 if let Some(tracking) = validation.tracking.as_ref() {
485 match tracking.source {
486 crate::templates::ValidationTrackingSourceYaml::ApplyTracks => {
487 let tracks_rel = resolved
488 .schema
489 .apply
490 .as_ref()
491 .and_then(|a| a.tracks.as_deref());
492
493 let Some(tracks_rel) = tracks_rel else {
494 if tracking.required {
495 rep.push(error(
496 "tracking",
497 "Schema tracking is required but schema apply.tracks is not set",
498 ));
499 }
500 return Ok(());
501 };
502
503 if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
504 rep.push(error(
505 "tracking",
506 format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
507 ));
508 return Ok(());
509 }
510
511 let report_path = format!("changes/{change_id}/{tracks_rel}");
512 let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
513
514 let present = abs_path.exists();
515 if tracking.required && !present {
516 rep.push(error(
517 "tracking",
518 format!("Missing required tracking file: {report_path}"),
519 ));
520 }
521 if !present {
522 return Ok(());
523 }
524
525 match tracking.validate_as {
526 ValidatorId::TasksTrackingV1 => {
527 rep.extend(validate_tasks_tracking_path(
528 &abs_path,
529 &report_path,
530 strict,
531 ));
532 let ctx = ArtifactValidatorContext {
533 ito_path,
534 change_id,
535 strict,
536 };
537 rules_engine::run_tracking_rules(
538 rep,
539 change_repo,
540 ctx,
541 ValidatorId::TasksTrackingV1,
542 &abs_path,
543 &report_path,
544 tracking.rules.as_ref(),
545 )?;
546 }
547 ValidatorId::DeltaSpecsV1 => {
548 rep.push(error(
549 "schema.validation",
550 "Validator 'ito.delta-specs.v1' is not valid for tracking files",
551 ));
552 }
553 }
554 }
555 }
556 }
557
558 Ok(())
559}
560
561fn run_validator_for_artifact(
563 rep: &mut ReportBuilder,
564 change_repo: &(impl DomainChangeRepository + ?Sized),
565 ctx: ArtifactValidatorContext<'_>,
566 artifact_id: &str,
567 generates: &str,
568 validator_id: ValidatorId,
569) -> CoreResult<()> {
570 match validator_id {
571 ValidatorId::DeltaSpecsV1 => {
572 validate_change_delta_specs(rep, change_repo, ctx.change_id, ctx.strict)?;
573 }
574 ValidatorId::TasksTrackingV1 => {
575 use format_specs::TASKS_TRACKING_V1;
576
577 if generates.contains('*') {
578 rep.push(with_format_spec(
579 error(
580 format!("artifacts.{artifact_id}"),
581 format!(
582 "Validator '{}' requires a single file path; got pattern '{}'",
583 TASKS_TRACKING_V1.validator_id, generates
584 ),
585 ),
586 TASKS_TRACKING_V1,
587 ));
588 return Ok(());
589 }
590
591 let report_path = format!("changes/{}/{generates}", ctx.change_id);
592 let abs_path = paths::change_dir(ctx.ito_path, ctx.change_id).join(generates);
593 rep.extend(validate_tasks_tracking_path(
594 &abs_path,
595 &report_path,
596 ctx.strict,
597 ));
598 }
599 }
600 Ok(())
601}
602
603#[derive(Debug, Clone, Copy)]
604struct ArtifactValidatorContext<'a> {
605 ito_path: &'a Path,
606 change_id: &'a str,
607 strict: bool,
608}
609
610fn validate_tasks_tracking_path(
611 path: &Path,
612 report_path: &str,
613 strict: bool,
614) -> Vec<ValidationIssue> {
615 use format_specs::TASKS_TRACKING_V1;
616 use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file};
617
618 let contents = match ito_common::io::read_to_string(path) {
619 Ok(c) => c,
620 Err(e) => {
621 return vec![with_format_spec(
622 error(report_path, format!("Failed to read {report_path}: {e}")),
623 TASKS_TRACKING_V1,
624 )];
625 }
626 };
627
628 let parsed = parse_tasks_tracking_file(&contents);
629 let mut issues = Vec::new();
630
631 if parsed.tasks.is_empty() {
632 let msg = "Tracking file contains no recognizable tasks";
633 let i = if strict {
634 error(report_path, msg)
635 } else {
636 warning(report_path, msg)
637 };
638 issues.push(with_format_spec(i, TASKS_TRACKING_V1));
639 }
640 for d in &parsed.diagnostics {
641 let level = match d.level {
642 DiagnosticLevel::Error => LEVEL_ERROR,
643 DiagnosticLevel::Warning => LEVEL_WARNING,
644 };
645 issues.push(with_format_spec(
646 ValidationIssue {
647 path: report_path.to_string(),
648 level: level.to_string(),
649 message: d.message.clone(),
650 line: d.line.map(|l| l as u32),
651 column: None,
652 rule_id: None,
653 metadata: None,
654 },
655 TASKS_TRACKING_V1,
656 ));
657 }
658 issues
659}
660
661fn validate_change_delta_specs(
663 rep: &mut ReportBuilder,
664 change_repo: &(impl DomainChangeRepository + ?Sized),
665 change_id: &str,
666 strict: bool,
667) -> CoreResult<()> {
668 use format_specs::DELTA_SPECS_V1;
669
670 let files = read_change_delta_spec_files(change_repo, change_id)?;
671 if files.is_empty() {
672 rep.push(with_format_spec(
673 error("specs", "Change must have at least one delta"),
674 DELTA_SPECS_V1,
675 ));
676 return Ok(());
677 }
678
679 let show = parse_change_show_json(change_id, &files);
680 if show.deltas.is_empty() {
681 rep.push(with_format_spec(
682 error("specs", "Change must have at least one delta"),
683 DELTA_SPECS_V1,
684 ));
685 return Ok(());
686 }
687
688 if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
689 rep.push(with_format_spec(
690 info(
691 "deltas",
692 "Consider splitting changes with more than 10 deltas",
693 ),
694 DELTA_SPECS_V1,
695 ));
696 }
697
698 for (idx, d) in show.deltas.iter().enumerate() {
699 let base = format!("deltas[{idx}]");
700 if d.description.trim().is_empty() {
701 rep.push(with_format_spec(
702 error(&base, "Delta description cannot be empty"),
703 DELTA_SPECS_V1,
704 ));
705 } else if d.description.trim().len() < 20 {
706 rep.push(with_format_spec(
707 warning(&base, "Delta description is too brief"),
708 DELTA_SPECS_V1,
709 ));
710 }
711
712 if d.requirements.is_empty() {
713 rep.push(with_format_spec(
714 warning(&base, "Delta should include requirements"),
715 DELTA_SPECS_V1,
716 ));
717 }
718
719 for (ridx, req) in d.requirements.iter().enumerate() {
720 let rp = format!("{base}.requirements[{ridx}]");
721 if req.text.trim().is_empty() {
722 rep.push(with_format_spec(
723 error(&rp, "Requirement text cannot be empty"),
724 DELTA_SPECS_V1,
725 ));
726 }
727 let up = req.text.to_ascii_uppercase();
728 if !up.contains("SHALL") && !up.contains("MUST") {
729 rep.push(with_format_spec(
730 error(&rp, "Requirement must contain SHALL or MUST keyword"),
731 DELTA_SPECS_V1,
732 ));
733 }
734 if req.scenarios.is_empty() {
735 rep.push(with_format_spec(
736 error(&rp, "Requirement must have at least one scenario"),
737 DELTA_SPECS_V1,
738 ));
739 }
740 }
741 }
742
743 let mut delta_requirements: Vec<(String, Option<String>)> = Vec::new();
746 for d in &show.deltas {
747 for req in &d.requirements {
748 delta_requirements.push((req.text.clone(), req.requirement_id.clone()));
749 }
750 }
751
752 let has_any_id = delta_requirements.iter().any(|(_, id)| id.is_some());
754 if has_any_id {
755 let change_data = change_repo.get(change_id).into_core()?;
756 let trace_result =
757 ito_domain::traceability::compute_traceability(&delta_requirements, &change_data.tasks);
758
759 match &trace_result.status {
760 ito_domain::traceability::TraceStatus::Invalid { missing_ids } => {
761 for title in missing_ids {
762 rep.push(with_format_spec(
763 error(
764 "traceability",
765 format!(
766 "Requirement '{}' has no Requirement ID; all requirements must have IDs for traceability",
767 title
768 ),
769 ),
770 DELTA_SPECS_V1,
771 ));
772 }
773 }
774 ito_domain::traceability::TraceStatus::Unavailable { reason } => {
775 rep.push(with_format_spec(
776 info(
777 "traceability",
778 format!("Traceability unavailable: {reason}"),
779 ),
780 DELTA_SPECS_V1,
781 ));
782 }
783 ito_domain::traceability::TraceStatus::Ready => {
784 for diag in &trace_result.diagnostics {
785 rep.push(with_format_spec(
786 error("traceability", diag.clone()),
787 DELTA_SPECS_V1,
788 ));
789 }
790 for unresolved in &trace_result.unresolved_references {
791 rep.push(with_format_spec(
792 error(
793 "traceability",
794 format!(
795 "Task '{}' references unknown requirement ID '{}'",
796 unresolved.task_id, unresolved.requirement_id
797 ),
798 ),
799 DELTA_SPECS_V1,
800 ));
801 }
802 for uncovered in &trace_result.uncovered_requirements {
803 let i = if strict {
804 error(
805 "traceability",
806 format!(
807 "Requirement '{}' is not covered by any active task",
808 uncovered
809 ),
810 )
811 } else {
812 warning(
813 "traceability",
814 format!(
815 "Requirement '{}' is not covered by any active task",
816 uncovered
817 ),
818 )
819 };
820 rep.push(with_format_spec(i, DELTA_SPECS_V1));
821 }
822 }
823 }
824 }
825
826 Ok(())
827}
828
829#[derive(Debug, Clone)]
830pub struct ResolvedModule {
832 pub id: String,
834 pub full_name: String,
836 pub module_dir: PathBuf,
838 pub module_md: PathBuf,
840}
841
842pub fn resolve_module(
844 module_repo: &(impl DomainModuleRepository + ?Sized),
845 ito_path: &Path,
846 input: &str,
847) -> CoreResult<Option<ResolvedModule>> {
848 let trimmed = input.trim();
849 if trimmed.is_empty() {
850 return Ok(None);
851 }
852
853 let module = module_repo.get(trimmed).into_core();
854 match module {
855 Ok(m) => {
856 let full_name = format!("{}_{}", m.id, m.name);
857 let module_dir = if m.path.as_os_str().is_empty() {
858 let fallback = paths::modules_dir(ito_path).join(&full_name);
859 if !fallback.exists() {
860 return Ok(None);
861 }
862 fallback
863 } else {
864 m.path
865 };
866 let module_md = module_dir.join("module.md");
867 Ok(Some(ResolvedModule {
868 id: m.id,
869 full_name,
870 module_dir,
871 module_md,
872 }))
873 }
874 Err(_) => Ok(None),
875 }
876}
877
878pub fn validate_module(
880 module_repo: &(impl DomainModuleRepository + ?Sized),
881 ito_path: &Path,
882 module_input: &str,
883 strict: bool,
884) -> CoreResult<(String, ValidationReport)> {
885 let resolved = resolve_module(module_repo, ito_path, module_input)?;
886 let Some(r) = resolved else {
887 let mut rep = report(strict);
888 rep.push(error("module", "Module not found"));
889 return Ok((module_input.to_string(), rep.finish()));
890 };
891
892 let mut rep = report(strict);
893 let md = match ito_common::io::read_to_string_std(&r.module_md) {
894 Ok(c) => c,
895 Err(_) => {
896 rep.push(error("file", "Module must have a Purpose section"));
897 return Ok((r.full_name, rep.finish()));
898 }
899 };
900
901 let purpose = extract_section(&md, "Purpose");
902 if purpose.trim().is_empty() {
903 rep.push(error("purpose", "Module must have a Purpose section"));
904 } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
905 rep.push(error(
906 "purpose",
907 "Module purpose must be at least 20 characters",
908 ));
909 }
910
911 let scope = extract_section(&md, "Scope");
912 if scope.trim().is_empty() {
913 rep.push(error(
914 "scope",
915 "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
916 ));
917 }
918
919 validate_sub_modules_under_module(&mut rep, module_repo, &r.module_dir, &r.id, strict);
921
922 Ok((r.full_name, rep.finish()))
923}
924
925fn validate_sub_modules_under_module(
927 rep: &mut ReportBuilder,
928 module_repo: &(impl DomainModuleRepository + ?Sized),
929 module_dir: &Path,
930 parent_id: &str,
931 strict: bool,
932) {
933 let sub_dir = module_dir.join("sub");
934 if !sub_dir.exists() {
935 return;
936 }
937
938 let module = match module_repo.get(parent_id) {
941 Ok(m) => m,
942 Err(_) => return, };
944
945 let mut recognized_dirs: std::collections::HashSet<String> =
948 std::collections::HashSet::with_capacity(module.sub_modules.len());
949
950 for sm in &module.sub_modules {
951 let dir_name = sm
952 .path
953 .file_name()
954 .and_then(|n| n.to_str())
955 .unwrap_or(&sm.name)
956 .to_string();
957 recognized_dirs.insert(dir_name.clone());
958
959 if sm.sub_id.len() != 2 || !sm.sub_id.bytes().all(|b| b.is_ascii_digit()) {
961 rep.push(error(
962 format!("sub-modules/{dir_name}"),
963 format!("Sub-module directory '{dir_name}' does not follow the SS_name convention"),
964 ));
965 continue;
966 }
967
968 let module_md = sm.path.join("module.md");
970 if !module_md.exists() {
971 let level = if strict { LEVEL_ERROR } else { LEVEL_WARNING };
972 rep.push(issue(
973 level,
974 format!("sub-modules/{dir_name}"),
975 format!("Sub-module '{dir_name}' is missing module.md"),
976 ));
977 continue;
978 }
979
980 let content = match ito_common::io::read_to_string_std(&module_md) {
982 Ok(c) => c,
983 Err(err) => {
984 rep.push(error(
985 format!("sub-modules/{dir_name}/module.md"),
986 format!("Failed to read module.md: {err}"),
987 ));
988 continue;
989 }
990 };
991
992 let purpose = extract_section(&content, "Purpose");
993 if purpose.trim().is_empty() {
994 rep.push(error(
995 format!("sub-modules/{dir_name}/purpose"),
996 format!("Sub-module '{dir_name}' module.md must have a Purpose section"),
997 ));
998 } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
999 rep.push(warning(
1000 format!("sub-modules/{dir_name}/purpose"),
1001 format!(
1002 "Sub-module '{dir_name}' purpose is too brief (less than {MIN_MODULE_PURPOSE_LENGTH} characters)"
1003 ),
1004 ));
1005 }
1006 }
1007
1008 if let Ok(entries) = std::fs::read_dir(&sub_dir) {
1011 for entry in entries.flatten() {
1012 let path = entry.path();
1013 if !path.is_dir() {
1014 continue;
1015 }
1016 let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
1017 continue;
1018 };
1019 if !recognized_dirs.contains(dir_name) {
1020 rep.push(error(
1021 format!("sub-modules/{dir_name}"),
1022 format!(
1023 "Sub-module directory '{dir_name}' does not follow the SS_name convention"
1024 ),
1025 ));
1026 }
1027 }
1028 }
1029}
1030
1031fn extract_section(markdown: &str, header: &str) -> String {
1032 let mut in_section = false;
1033 let mut out = String::new();
1034 let normalized = markdown.replace('\r', "");
1035 for raw in normalized.split('\n') {
1036 let line = raw.trim_end();
1037 if let Some(h) = line.strip_prefix("## ") {
1038 let title = h.trim();
1039 if title.eq_ignore_ascii_case(header) {
1040 in_section = true;
1041 continue;
1042 }
1043 if in_section {
1044 break;
1045 }
1046 }
1047 if in_section {
1048 out.push_str(line);
1049 out.push('\n');
1050 }
1051 }
1052 out
1053}
1054
1055pub fn validate_tasks_file(
1057 ito_path: &Path,
1058 change_id: &str,
1059 strict: bool,
1060) -> CoreResult<Vec<ValidationIssue>> {
1061 use crate::templates::{load_schema_validation, read_change_schema, resolve_schema};
1062 use ito_domain::tasks::tasks_path_checked;
1063
1064 if tasks_path_checked(ito_path, change_id).is_none() {
1066 return Ok(vec![error(
1067 "tracking",
1068 format!("invalid change id path segment: \"{change_id}\""),
1069 )]);
1070 }
1071
1072 let schema_name = read_change_schema(ito_path, change_id);
1073 let mut ctx = ConfigContext::from_process_env();
1074 ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
1075
1076 let mut issues: Vec<ValidationIssue> = Vec::new();
1077
1078 let mut tracking_file = "tasks.md".to_string();
1079 let resolved = match resolve_schema(Some(&schema_name), &ctx) {
1080 Ok(r) => Some(r),
1081 Err(e) => {
1082 issues.push(error(
1083 "schema",
1084 format!("Failed to resolve schema '{schema_name}': {e}"),
1085 ));
1086 None
1087 }
1088 };
1089
1090 if let Some(resolved) = resolved.as_ref() {
1091 if let Ok(Some(validation)) = load_schema_validation(resolved)
1094 && let Some(tracking) = validation.tracking.as_ref()
1095 && tracking.validate_as != ValidatorId::TasksTrackingV1
1096 {
1097 issues.push(error(
1098 "tracking",
1099 format!(
1100 "Schema tracking validator '{}' is not valid for tasks tracking files",
1101 tracking.validate_as.as_str()
1102 ),
1103 ));
1104 return Ok(issues);
1105 }
1106
1107 if let Some(tracks) = resolved
1108 .schema
1109 .apply
1110 .as_ref()
1111 .and_then(|a| a.tracks.as_deref())
1112 {
1113 tracking_file = tracks.to_string();
1114 }
1115 }
1116
1117 if !ito_domain::tasks::is_safe_tracking_filename(&tracking_file) {
1118 issues.push(error(
1119 "tracking",
1120 format!("Invalid tracking file path in apply.tracks: '{tracking_file}'"),
1121 ));
1122 return Ok(issues);
1123 }
1124
1125 let path = paths::change_dir(ito_path, change_id).join(&tracking_file);
1126 let report_path = format!("changes/{change_id}/{tracking_file}");
1127 issues.extend(validate_tasks_tracking_path(&path, &report_path, strict));
1128 Ok(issues)
1129}