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(
200 change_repo: &(impl DomainChangeRepository + ?Sized),
201 ito_path: &Path,
202 change_id: &str,
203 strict: bool,
204) -> CoreResult<ValidationReport> {
205 let mut rep = report(strict);
206
207 let (ctx, schema_name) = resolve_validation_context(ito_path, change_id);
208
209 let resolved = match resolve_schema(Some(&schema_name), &ctx) {
210 Ok(s) => {
211 rep.push(info(
212 "schema",
213 format!(
214 "Resolved schema '{}' from {}",
215 s.schema.name,
216 s.source.as_str()
217 ),
218 ));
219 Some(s)
220 }
221 Err(e) => {
222 rep.push(error(
223 "schema",
224 format!("Failed to resolve schema '{schema_name}': {e}"),
225 ));
226 None
227 }
228 };
229
230 if let Some(resolved) = &resolved {
231 match load_schema_validation(resolved) {
232 Ok(Some(validation)) => {
233 rep.push(info("schema.validation", "Using schema validation.yaml"));
234 validate_change_against_schema_validation(
235 &mut rep,
236 change_repo,
237 ito_path,
238 change_id,
239 resolved,
240 &validation,
241 strict,
242 )?;
243 return Ok(rep.finish());
244 }
245 Ok(None) => {}
246 Err(e) => {
247 rep.push(error(
248 "schema.validation",
249 format!("Failed to load schema validation.yaml: {e}"),
250 ));
251 return Ok(rep.finish());
252 }
253 }
254
255 if is_legacy_delta_schema(&resolved.schema.name) {
256 validate_change_delta_specs(&mut rep, change_repo, change_id, strict)?;
257
258 let tracks_rel = resolved
259 .schema
260 .apply
261 .as_ref()
262 .and_then(|a| a.tracks.as_deref())
263 .unwrap_or("tasks.md");
264
265 if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
266 rep.push(error(
267 "tracking",
268 format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
269 ));
270 return Ok(rep.finish());
271 }
272
273 let report_path = format!("changes/{change_id}/{tracks_rel}");
274 let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
275 rep.extend(validate_tasks_tracking_path(
276 &abs_path,
277 &report_path,
278 strict,
279 ));
280 return Ok(rep.finish());
281 }
282
283 rep.push(info(
284 "schema.validation",
285 "Schema has no validation.yaml; manual validation required",
286 ));
287 validate_apply_required_artifacts(&mut rep, ito_path, change_id, resolved);
288 return Ok(rep.finish());
289 }
290
291 validate_change_delta_specs(&mut rep, change_repo, change_id, strict)?;
292 Ok(rep.finish())
293}
294
295fn is_legacy_delta_schema(schema_name: &str) -> bool {
297 schema_name == "spec-driven" || schema_name == "tdd"
298}
299
300fn schema_artifact_ids(resolved: &ResolvedSchema) -> Vec<String> {
301 let mut ids = Vec::new();
302 for a in &resolved.schema.artifacts {
303 ids.push(a.id.clone());
304 }
305 ids
306}
307
308fn validate_apply_required_artifacts(
309 rep: &mut ReportBuilder,
310 ito_path: &Path,
311 change_id: &str,
312 resolved: &ResolvedSchema,
313) {
314 let change_dir = paths::change_dir(ito_path, change_id);
315 if !change_dir.exists() {
316 rep.push(error(
317 "change",
318 format!("Change directory not found: changes/{change_id}"),
319 ));
320 return;
321 }
322
323 let required_ids: Vec<String> = match resolved.schema.apply.as_ref() {
324 Some(apply) => apply
325 .requires
326 .clone()
327 .unwrap_or_else(|| schema_artifact_ids(resolved)),
328 None => schema_artifact_ids(resolved),
329 };
330
331 for id in required_ids {
332 let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
333 rep.push(error(
334 "schema.validation",
335 format!("Schema apply.requires references unknown artifact id '{id}'"),
336 ));
337 continue;
338 };
339 if artifact_done(&change_dir, &a.generates) {
340 continue;
341 }
342 rep.push(warning(
343 format!("artifacts.{id}"),
344 format!(
345 "Apply-required artifact '{id}' is missing (expected output: {})",
346 a.generates
347 ),
348 ));
349 }
350}
351
352fn resolve_validation_context(ito_path: &Path, change_id: &str) -> (ConfigContext, String) {
353 let schema_name = read_change_schema(ito_path, change_id);
354
355 let mut ctx = ConfigContext::from_process_env();
356 ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
357
358 (ctx, schema_name)
359}
360
361fn validate_change_against_schema_validation(
362 rep: &mut ReportBuilder,
363 change_repo: &(impl DomainChangeRepository + ?Sized),
364 ito_path: &Path,
365 change_id: &str,
366 resolved: &ResolvedSchema,
367 validation: &ValidationYaml,
368 strict: bool,
369) -> CoreResult<()> {
370 let change_dir = paths::change_dir(ito_path, change_id);
371
372 let missing_level = validation
373 .defaults
374 .missing_required_artifact_level
375 .unwrap_or(ValidationLevelYaml::Warning)
376 .as_level_str();
377
378 for (artifact_id, cfg) in &validation.artifacts {
379 let Some(schema_artifact) = resolved
380 .schema
381 .artifacts
382 .iter()
383 .find(|a| a.id == *artifact_id)
384 else {
385 rep.push(error(
386 "schema.validation",
387 format!("validation.yaml references unknown artifact id '{artifact_id}'"),
388 ));
389 continue;
390 };
391
392 let present = artifact_done(&change_dir, &schema_artifact.generates);
393 if cfg.required && !present {
394 rep.push(issue(
395 missing_level,
396 format!("artifacts.{artifact_id}"),
397 format!(
398 "Missing required artifact '{artifact_id}' (expected output: {})",
399 schema_artifact.generates
400 ),
401 ));
402 }
403
404 if !present {
405 if let Some(validator_id @ ValidatorId::DeltaSpecsV1) = cfg.validate_as {
406 let ctx = ArtifactValidatorContext {
409 ito_path,
410 change_id,
411 strict,
412 };
413 run_validator_for_artifact(
414 rep,
415 change_repo,
416 ctx,
417 artifact_id,
418 &schema_artifact.generates,
419 validator_id,
420 )?;
421 }
422 continue;
423 }
424
425 let Some(validator_id) = cfg.validate_as else {
426 continue;
427 };
428 let ctx = ArtifactValidatorContext {
429 ito_path,
430 change_id,
431 strict,
432 };
433 run_validator_for_artifact(
434 rep,
435 change_repo,
436 ctx,
437 artifact_id,
438 &schema_artifact.generates,
439 validator_id,
440 )?;
441 }
442
443 if let Some(tracking) = validation.tracking.as_ref() {
444 match tracking.source {
445 crate::templates::ValidationTrackingSourceYaml::ApplyTracks => {
446 let tracks_rel = resolved
447 .schema
448 .apply
449 .as_ref()
450 .and_then(|a| a.tracks.as_deref());
451
452 let Some(tracks_rel) = tracks_rel else {
453 if tracking.required {
454 rep.push(error(
455 "tracking",
456 "Schema tracking is required but schema apply.tracks is not set",
457 ));
458 }
459 return Ok(());
460 };
461
462 if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
463 rep.push(error(
464 "tracking",
465 format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
466 ));
467 return Ok(());
468 }
469
470 let report_path = format!("changes/{change_id}/{tracks_rel}");
471 let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
472
473 let present = abs_path.exists();
474 if tracking.required && !present {
475 rep.push(error(
476 "tracking",
477 format!("Missing required tracking file: {report_path}"),
478 ));
479 }
480 if !present {
481 return Ok(());
482 }
483
484 match tracking.validate_as {
485 ValidatorId::TasksTrackingV1 => {
486 rep.extend(validate_tasks_tracking_path(
487 &abs_path,
488 &report_path,
489 strict,
490 ));
491 }
492 ValidatorId::DeltaSpecsV1 => {
493 rep.push(error(
494 "schema.validation",
495 "Validator 'ito.delta-specs.v1' is not valid for tracking files",
496 ));
497 }
498 }
499 }
500 }
501 }
502
503 Ok(())
504}
505
506fn run_validator_for_artifact(
540 rep: &mut ReportBuilder,
541 change_repo: &(impl DomainChangeRepository + ?Sized),
542 ctx: ArtifactValidatorContext<'_>,
543 artifact_id: &str,
544 generates: &str,
545 validator_id: ValidatorId,
546) -> CoreResult<()> {
547 match validator_id {
548 ValidatorId::DeltaSpecsV1 => {
549 validate_change_delta_specs(rep, change_repo, ctx.change_id, ctx.strict)?;
550 }
551 ValidatorId::TasksTrackingV1 => {
552 use format_specs::TASKS_TRACKING_V1;
553
554 if generates.contains('*') {
555 rep.push(with_format_spec(
556 error(
557 format!("artifacts.{artifact_id}"),
558 format!(
559 "Validator '{}' requires a single file path; got pattern '{}'",
560 TASKS_TRACKING_V1.validator_id, generates
561 ),
562 ),
563 TASKS_TRACKING_V1,
564 ));
565 return Ok(());
566 }
567
568 let report_path = format!("changes/{}/{generates}", ctx.change_id);
569 let abs_path = paths::change_dir(ctx.ito_path, ctx.change_id).join(generates);
570 rep.extend(validate_tasks_tracking_path(
571 &abs_path,
572 &report_path,
573 ctx.strict,
574 ));
575 }
576 }
577 Ok(())
578}
579
580#[derive(Debug, Clone, Copy)]
581struct ArtifactValidatorContext<'a> {
582 ito_path: &'a Path,
583 change_id: &'a str,
584 strict: bool,
585}
586
587fn validate_tasks_tracking_path(
588 path: &Path,
589 report_path: &str,
590 strict: bool,
591) -> Vec<ValidationIssue> {
592 use format_specs::TASKS_TRACKING_V1;
593 use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file};
594
595 let contents = match ito_common::io::read_to_string(path) {
596 Ok(c) => c,
597 Err(e) => {
598 return vec![with_format_spec(
599 error(report_path, format!("Failed to read {report_path}: {e}")),
600 TASKS_TRACKING_V1,
601 )];
602 }
603 };
604
605 let parsed = parse_tasks_tracking_file(&contents);
606 let mut issues = Vec::new();
607
608 if parsed.tasks.is_empty() {
609 let msg = "Tracking file contains no recognizable tasks";
610 let i = if strict {
611 error(report_path, msg)
612 } else {
613 warning(report_path, msg)
614 };
615 issues.push(with_format_spec(i, TASKS_TRACKING_V1));
616 }
617 for d in &parsed.diagnostics {
618 let level = match d.level {
619 DiagnosticLevel::Error => LEVEL_ERROR,
620 DiagnosticLevel::Warning => LEVEL_WARNING,
621 };
622 issues.push(with_format_spec(
623 ValidationIssue {
624 path: report_path.to_string(),
625 level: level.to_string(),
626 message: d.message.clone(),
627 line: d.line.map(|l| l as u32),
628 column: None,
629 metadata: None,
630 },
631 TASKS_TRACKING_V1,
632 ));
633 }
634 issues
635}
636
637fn validate_change_delta_specs(
662 rep: &mut ReportBuilder,
663 change_repo: &(impl DomainChangeRepository + ?Sized),
664 change_id: &str,
665 strict: bool,
666) -> CoreResult<()> {
667 use format_specs::DELTA_SPECS_V1;
668
669 let files = read_change_delta_spec_files(change_repo, change_id)?;
670 if files.is_empty() {
671 rep.push(with_format_spec(
672 error("specs", "Change must have at least one delta"),
673 DELTA_SPECS_V1,
674 ));
675 return Ok(());
676 }
677
678 let show = parse_change_show_json(change_id, &files);
679 if show.deltas.is_empty() {
680 rep.push(with_format_spec(
681 error("specs", "Change must have at least one delta"),
682 DELTA_SPECS_V1,
683 ));
684 return Ok(());
685 }
686
687 if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
688 rep.push(with_format_spec(
689 info(
690 "deltas",
691 "Consider splitting changes with more than 10 deltas",
692 ),
693 DELTA_SPECS_V1,
694 ));
695 }
696
697 for (idx, d) in show.deltas.iter().enumerate() {
698 let base = format!("deltas[{idx}]");
699 if d.description.trim().is_empty() {
700 rep.push(with_format_spec(
701 error(&base, "Delta description cannot be empty"),
702 DELTA_SPECS_V1,
703 ));
704 } else if d.description.trim().len() < 20 {
705 rep.push(with_format_spec(
706 warning(&base, "Delta description is too brief"),
707 DELTA_SPECS_V1,
708 ));
709 }
710
711 if d.requirements.is_empty() {
712 rep.push(with_format_spec(
713 warning(&base, "Delta should include requirements"),
714 DELTA_SPECS_V1,
715 ));
716 }
717
718 for (ridx, req) in d.requirements.iter().enumerate() {
719 let rp = format!("{base}.requirements[{ridx}]");
720 if req.text.trim().is_empty() {
721 rep.push(with_format_spec(
722 error(&rp, "Requirement text cannot be empty"),
723 DELTA_SPECS_V1,
724 ));
725 }
726 let up = req.text.to_ascii_uppercase();
727 if !up.contains("SHALL") && !up.contains("MUST") {
728 rep.push(with_format_spec(
729 error(&rp, "Requirement must contain SHALL or MUST keyword"),
730 DELTA_SPECS_V1,
731 ));
732 }
733 if req.scenarios.is_empty() {
734 rep.push(with_format_spec(
735 error(&rp, "Requirement must have at least one scenario"),
736 DELTA_SPECS_V1,
737 ));
738 }
739 }
740 }
741
742 let mut delta_requirements: Vec<(String, Option<String>)> = Vec::new();
745 for d in &show.deltas {
746 for req in &d.requirements {
747 delta_requirements.push((req.text.clone(), req.requirement_id.clone()));
748 }
749 }
750
751 let has_any_id = delta_requirements.iter().any(|(_, id)| id.is_some());
753 if has_any_id {
754 let change_data = change_repo.get(change_id).into_core()?;
755 let trace_result =
756 ito_domain::traceability::compute_traceability(&delta_requirements, &change_data.tasks);
757
758 match &trace_result.status {
759 ito_domain::traceability::TraceStatus::Invalid { missing_ids } => {
760 for title in missing_ids {
761 rep.push(with_format_spec(
762 error(
763 "traceability",
764 format!(
765 "Requirement '{}' has no Requirement ID; all requirements must have IDs for traceability",
766 title
767 ),
768 ),
769 DELTA_SPECS_V1,
770 ));
771 }
772 }
773 ito_domain::traceability::TraceStatus::Unavailable { reason } => {
774 rep.push(with_format_spec(
775 info(
776 "traceability",
777 format!("Traceability unavailable: {reason}"),
778 ),
779 DELTA_SPECS_V1,
780 ));
781 }
782 ito_domain::traceability::TraceStatus::Ready => {
783 for diag in &trace_result.diagnostics {
784 rep.push(with_format_spec(
785 error("traceability", diag.clone()),
786 DELTA_SPECS_V1,
787 ));
788 }
789 for unresolved in &trace_result.unresolved_references {
790 rep.push(with_format_spec(
791 error(
792 "traceability",
793 format!(
794 "Task '{}' references unknown requirement ID '{}'",
795 unresolved.task_id, unresolved.requirement_id
796 ),
797 ),
798 DELTA_SPECS_V1,
799 ));
800 }
801 for uncovered in &trace_result.uncovered_requirements {
802 let i = if strict {
803 error(
804 "traceability",
805 format!(
806 "Requirement '{}' is not covered by any active task",
807 uncovered
808 ),
809 )
810 } else {
811 warning(
812 "traceability",
813 format!(
814 "Requirement '{}' is not covered by any active task",
815 uncovered
816 ),
817 )
818 };
819 rep.push(with_format_spec(i, DELTA_SPECS_V1));
820 }
821 }
822 }
823 }
824
825 Ok(())
826}
827
828#[derive(Debug, Clone)]
829pub struct ResolvedModule {
831 pub id: String,
833 pub full_name: String,
835 pub module_dir: PathBuf,
837 pub module_md: PathBuf,
839}
840
841pub fn resolve_module(
846 module_repo: &(impl DomainModuleRepository + ?Sized),
847 ito_path: &Path,
848 input: &str,
849) -> CoreResult<Option<ResolvedModule>> {
850 let trimmed = input.trim();
851 if trimmed.is_empty() {
852 return Ok(None);
853 }
854
855 let module = module_repo.get(trimmed).into_core();
856 match module {
857 Ok(m) => {
858 let full_name = format!("{}_{}", m.id, m.name);
859 let module_dir = if m.path.as_os_str().is_empty() {
860 let fallback = paths::modules_dir(ito_path).join(&full_name);
861 if !fallback.exists() {
862 return Ok(None);
863 }
864 fallback
865 } else {
866 m.path
867 };
868 let module_md = module_dir.join("module.md");
869 Ok(Some(ResolvedModule {
870 id: m.id,
871 full_name,
872 module_dir,
873 module_md,
874 }))
875 }
876 Err(_) => Ok(None),
877 }
878}
879
880pub fn validate_module(
887 module_repo: &(impl DomainModuleRepository + ?Sized),
888 ito_path: &Path,
889 module_input: &str,
890 strict: bool,
891) -> CoreResult<(String, ValidationReport)> {
892 let resolved = resolve_module(module_repo, ito_path, module_input)?;
893 let Some(r) = resolved else {
894 let mut rep = report(strict);
895 rep.push(error("module", "Module not found"));
896 return Ok((module_input.to_string(), rep.finish()));
897 };
898
899 let mut rep = report(strict);
900 let md = match ito_common::io::read_to_string_std(&r.module_md) {
901 Ok(c) => c,
902 Err(_) => {
903 rep.push(error("file", "Module must have a Purpose section"));
904 return Ok((r.full_name, rep.finish()));
905 }
906 };
907
908 let purpose = extract_section(&md, "Purpose");
909 if purpose.trim().is_empty() {
910 rep.push(error("purpose", "Module must have a Purpose section"));
911 } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
912 rep.push(error(
913 "purpose",
914 "Module purpose must be at least 20 characters",
915 ));
916 }
917
918 let scope = extract_section(&md, "Scope");
919 if scope.trim().is_empty() {
920 rep.push(error(
921 "scope",
922 "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
923 ));
924 }
925
926 validate_sub_modules_under_module(&mut rep, module_repo, &r.module_dir, &r.id, strict);
928
929 Ok((r.full_name, rep.finish()))
930}
931
932fn validate_sub_modules_under_module(
939 rep: &mut ReportBuilder,
940 module_repo: &(impl DomainModuleRepository + ?Sized),
941 module_dir: &Path,
942 parent_id: &str,
943 strict: bool,
944) {
945 let sub_dir = module_dir.join("sub");
946 if !sub_dir.exists() {
947 return;
948 }
949
950 let module = match module_repo.get(parent_id) {
953 Ok(m) => m,
954 Err(_) => return, };
956
957 let mut recognized_dirs: std::collections::HashSet<String> =
960 std::collections::HashSet::with_capacity(module.sub_modules.len());
961
962 for sm in &module.sub_modules {
963 let dir_name = sm
964 .path
965 .file_name()
966 .and_then(|n| n.to_str())
967 .unwrap_or(&sm.name)
968 .to_string();
969 recognized_dirs.insert(dir_name.clone());
970
971 if sm.sub_id.len() != 2 || !sm.sub_id.bytes().all(|b| b.is_ascii_digit()) {
973 rep.push(error(
974 format!("sub-modules/{dir_name}"),
975 format!("Sub-module directory '{dir_name}' does not follow the SS_name convention"),
976 ));
977 continue;
978 }
979
980 let module_md = sm.path.join("module.md");
982 if !module_md.exists() {
983 let level = if strict { LEVEL_ERROR } else { LEVEL_WARNING };
984 rep.push(issue(
985 level,
986 format!("sub-modules/{dir_name}"),
987 format!("Sub-module '{dir_name}' is missing module.md"),
988 ));
989 continue;
990 }
991
992 let content = match ito_common::io::read_to_string_std(&module_md) {
994 Ok(c) => c,
995 Err(err) => {
996 rep.push(error(
997 format!("sub-modules/{dir_name}/module.md"),
998 format!("Failed to read module.md: {err}"),
999 ));
1000 continue;
1001 }
1002 };
1003
1004 let purpose = extract_section(&content, "Purpose");
1005 if purpose.trim().is_empty() {
1006 rep.push(error(
1007 format!("sub-modules/{dir_name}/purpose"),
1008 format!("Sub-module '{dir_name}' module.md must have a Purpose section"),
1009 ));
1010 } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
1011 rep.push(warning(
1012 format!("sub-modules/{dir_name}/purpose"),
1013 format!(
1014 "Sub-module '{dir_name}' purpose is too brief (less than {MIN_MODULE_PURPOSE_LENGTH} characters)"
1015 ),
1016 ));
1017 }
1018 }
1019
1020 if let Ok(entries) = std::fs::read_dir(&sub_dir) {
1023 for entry in entries.flatten() {
1024 let path = entry.path();
1025 if !path.is_dir() {
1026 continue;
1027 }
1028 let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
1029 continue;
1030 };
1031 if !recognized_dirs.contains(dir_name) {
1032 rep.push(error(
1033 format!("sub-modules/{dir_name}"),
1034 format!(
1035 "Sub-module directory '{dir_name}' does not follow the SS_name convention"
1036 ),
1037 ));
1038 }
1039 }
1040 }
1041}
1042
1043fn extract_section(markdown: &str, header: &str) -> String {
1044 let mut in_section = false;
1045 let mut out = String::new();
1046 let normalized = markdown.replace('\r', "");
1047 for raw in normalized.split('\n') {
1048 let line = raw.trim_end();
1049 if let Some(h) = line.strip_prefix("## ") {
1050 let title = h.trim();
1051 if title.eq_ignore_ascii_case(header) {
1052 in_section = true;
1053 continue;
1054 }
1055 if in_section {
1056 break;
1057 }
1058 }
1059 if in_section {
1060 out.push_str(line);
1061 out.push('\n');
1062 }
1063 }
1064 out
1065}
1066
1067pub fn validate_tasks_file(
1069 ito_path: &Path,
1070 change_id: &str,
1071 strict: bool,
1072) -> CoreResult<Vec<ValidationIssue>> {
1073 use crate::templates::{load_schema_validation, read_change_schema, resolve_schema};
1074 use ito_domain::tasks::tasks_path_checked;
1075
1076 if tasks_path_checked(ito_path, change_id).is_none() {
1078 return Ok(vec![error(
1079 "tracking",
1080 format!("invalid change id path segment: \"{change_id}\""),
1081 )]);
1082 }
1083
1084 let schema_name = read_change_schema(ito_path, change_id);
1085 let mut ctx = ConfigContext::from_process_env();
1086 ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
1087
1088 let mut issues: Vec<ValidationIssue> = Vec::new();
1089
1090 let mut tracking_file = "tasks.md".to_string();
1091 let resolved = match resolve_schema(Some(&schema_name), &ctx) {
1092 Ok(r) => Some(r),
1093 Err(e) => {
1094 issues.push(error(
1095 "schema",
1096 format!("Failed to resolve schema '{schema_name}': {e}"),
1097 ));
1098 None
1099 }
1100 };
1101
1102 if let Some(resolved) = resolved.as_ref() {
1103 if let Ok(Some(validation)) = load_schema_validation(resolved)
1106 && let Some(tracking) = validation.tracking.as_ref()
1107 && tracking.validate_as != ValidatorId::TasksTrackingV1
1108 {
1109 issues.push(error(
1110 "tracking",
1111 format!(
1112 "Schema tracking validator '{}' is not valid for tasks tracking files",
1113 tracking.validate_as.as_str()
1114 ),
1115 ));
1116 return Ok(issues);
1117 }
1118
1119 if let Some(tracks) = resolved
1120 .schema
1121 .apply
1122 .as_ref()
1123 .and_then(|a| a.tracks.as_deref())
1124 {
1125 tracking_file = tracks.to_string();
1126 }
1127 }
1128
1129 if !ito_domain::tasks::is_safe_tracking_filename(&tracking_file) {
1130 issues.push(error(
1131 "tracking",
1132 format!("Invalid tracking file path in apply.tracks: '{tracking_file}'"),
1133 ));
1134 return Ok(issues);
1135 }
1136
1137 let path = paths::change_dir(ito_path, change_id).join(&tracking_file);
1138 let report_path = format!("changes/{change_id}/{tracking_file}");
1139 issues.extend(validate_tasks_tracking_path(&path, &report_path, strict));
1140 Ok(issues)
1141}