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 ito_domain::changes::ChangeRepository as DomainChangeRepository;
19use ito_domain::modules::ModuleRepository as DomainModuleRepository;
20
21mod issue;
22mod repo_integrity;
23mod report;
24
25pub use issue::{error, info, issue, warning, with_line, with_loc, with_metadata};
26pub use repo_integrity::validate_change_dirs_repo_integrity;
27pub use report::{ReportBuilder, report};
28
29pub type ValidationLevel = &'static str;
31
32pub const LEVEL_ERROR: ValidationLevel = "ERROR";
34pub const LEVEL_WARNING: ValidationLevel = "WARNING";
36pub const LEVEL_INFO: ValidationLevel = "INFO";
38
39const MIN_PURPOSE_LENGTH: usize = 50;
41const MIN_MODULE_PURPOSE_LENGTH: usize = 20;
42const MAX_DELTAS_PER_CHANGE: usize = 10;
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct ValidationIssue {
47 pub level: String,
49 pub path: String,
51 pub message: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub line: Option<u32>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub column: Option<u32>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub metadata: Option<serde_json::Value>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct ValidationReport {
67 pub valid: bool,
69
70 pub issues: Vec<ValidationIssue>,
72
73 pub summary: ValidationSummary,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78pub struct ValidationSummary {
80 pub errors: u32,
82 pub warnings: u32,
84 pub info: u32,
86}
87
88impl ValidationReport {
89 pub fn new(issues: Vec<ValidationIssue>, strict: bool) -> Self {
93 let mut errors = 0u32;
94 let mut warnings = 0u32;
95 let mut info = 0u32;
96 for i in &issues {
97 match i.level.as_str() {
98 LEVEL_ERROR => errors += 1,
99 LEVEL_WARNING => warnings += 1,
100 LEVEL_INFO => info += 1,
101 _ => {}
102 }
103 }
104 let valid = if strict {
105 errors == 0 && warnings == 0
106 } else {
107 errors == 0
108 };
109 Self {
110 valid,
111 issues,
112 summary: ValidationSummary {
113 errors,
114 warnings,
115 info,
116 },
117 }
118 }
119}
120
121pub fn validate_spec_markdown(markdown: &str, strict: bool) -> ValidationReport {
123 let json = parse_spec_show_json("<spec>", markdown);
124
125 let mut r = report(strict);
126
127 if json.overview.trim().is_empty() {
128 r.push(error("purpose", "Purpose section cannot be empty"));
129 } else if json.overview.len() < MIN_PURPOSE_LENGTH {
130 r.push(warning(
131 "purpose",
132 "Purpose section is too brief (less than 50 characters)",
133 ));
134 }
135
136 if json.requirements.is_empty() {
137 r.push(error(
138 "requirements",
139 "Spec must have at least one requirement",
140 ));
141 }
142
143 for (idx, req) in json.requirements.iter().enumerate() {
144 let path = format!("requirements[{idx}]");
145 if req.text.trim().is_empty() {
146 r.push(error(&path, "Requirement text cannot be empty"));
147 }
148 if req.scenarios.is_empty() {
149 r.push(error(&path, "Requirement must have at least one scenario"));
150 }
151 for (sidx, sc) in req.scenarios.iter().enumerate() {
152 let sp = format!("{path}.scenarios[{sidx}]");
153 if sc.raw_text.trim().is_empty() {
154 r.push(error(&sp, "Scenario text cannot be empty"));
155 }
156 }
157 }
158
159 r.finish()
160}
161
162pub fn validate_spec(ito_path: &Path, spec_id: &str, strict: bool) -> CoreResult<ValidationReport> {
164 let path = paths::spec_markdown_path(ito_path, spec_id);
165 let markdown = ito_common::io::read_to_string_std(&path)
166 .map_err(|e| CoreError::io(format!("reading spec {}", spec_id), e))?;
167 Ok(validate_spec_markdown(&markdown, strict))
168}
169
170pub fn validate_change(
172 change_repo: &impl DomainChangeRepository,
173 change_id: &str,
174 strict: bool,
175) -> CoreResult<ValidationReport> {
176 let files = read_change_delta_spec_files(change_repo, change_id)?;
177 if files.is_empty() {
178 let mut r = report(strict);
179 r.push(error("specs", "Change must have at least one delta"));
180 return Ok(r.finish());
181 }
182
183 let show = parse_change_show_json(change_id, &files);
184 let mut rep = report(strict);
185 if show.deltas.is_empty() {
186 rep.push(error("specs", "Change must have at least one delta"));
187 return Ok(rep.finish());
188 }
189
190 if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
191 rep.push(info(
192 "deltas",
193 "Consider splitting changes with more than 10 deltas",
194 ));
195 }
196
197 for (idx, d) in show.deltas.iter().enumerate() {
198 let base = format!("deltas[{idx}]");
199 if d.description.trim().is_empty() {
200 rep.push(error(&base, "Delta description cannot be empty"));
201 } else if d.description.trim().len() < 20 {
202 rep.push(warning(&base, "Delta description is too brief"));
203 }
204
205 if d.requirements.is_empty() {
206 rep.push(warning(&base, "Delta should include requirements"));
207 }
208
209 for (ridx, req) in d.requirements.iter().enumerate() {
210 let rp = format!("{base}.requirements[{ridx}]");
211 if req.text.trim().is_empty() {
212 rep.push(error(&rp, "Requirement text cannot be empty"));
213 }
214 let up = req.text.to_ascii_uppercase();
215 if !up.contains("SHALL") && !up.contains("MUST") {
216 rep.push(error(&rp, "Requirement must contain SHALL or MUST keyword"));
217 }
218 if req.scenarios.is_empty() {
219 rep.push(error(&rp, "Requirement must have at least one scenario"));
220 }
221 }
222 }
223
224 Ok(rep.finish())
225}
226
227#[derive(Debug, Clone)]
228pub struct ResolvedModule {
230 pub id: String,
232 pub full_name: String,
234 pub module_dir: PathBuf,
236 pub module_md: PathBuf,
238}
239
240pub fn resolve_module(
245 module_repo: &impl DomainModuleRepository,
246 _ito_path: &Path,
247 input: &str,
248) -> CoreResult<Option<ResolvedModule>> {
249 let trimmed = input.trim();
250 if trimmed.is_empty() {
251 return Ok(None);
252 }
253
254 let module = module_repo.get(trimmed).into_core();
255 match module {
256 Ok(m) => {
257 let full_name = format!("{}_{}", m.id, m.name);
258 let module_dir = m.path;
259 let module_md = module_dir.join("module.md");
260 Ok(Some(ResolvedModule {
261 id: m.id,
262 full_name,
263 module_dir,
264 module_md,
265 }))
266 }
267 Err(_) => Ok(None),
268 }
269}
270
271pub fn validate_module(
275 module_repo: &impl DomainModuleRepository,
276 ito_path: &Path,
277 module_input: &str,
278 strict: bool,
279) -> CoreResult<(String, ValidationReport)> {
280 let resolved = resolve_module(module_repo, ito_path, module_input)?;
281 let Some(r) = resolved else {
282 let mut rep = report(strict);
283 rep.push(error("module", "Module not found"));
284 return Ok((module_input.to_string(), rep.finish()));
285 };
286
287 let mut rep = report(strict);
288 let md = match ito_common::io::read_to_string_std(&r.module_md) {
289 Ok(c) => c,
290 Err(_) => {
291 rep.push(error("file", "Module must have a Purpose section"));
292 return Ok((r.full_name, rep.finish()));
293 }
294 };
295
296 let purpose = extract_section(&md, "Purpose");
297 if purpose.trim().is_empty() {
298 rep.push(error("purpose", "Module must have a Purpose section"));
299 } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
300 rep.push(error(
301 "purpose",
302 "Module purpose must be at least 20 characters",
303 ));
304 }
305
306 let scope = extract_section(&md, "Scope");
307 if scope.trim().is_empty() {
308 rep.push(error(
309 "scope",
310 "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
311 ));
312 }
313
314 Ok((r.full_name, rep.finish()))
315}
316
317fn extract_section(markdown: &str, header: &str) -> String {
318 let mut in_section = false;
319 let mut out = String::new();
320 let normalized = markdown.replace('\r', "");
321 for raw in normalized.split('\n') {
322 let line = raw.trim_end();
323 if let Some(h) = line.strip_prefix("## ") {
324 let title = h.trim();
325 if title.eq_ignore_ascii_case(header) {
326 in_section = true;
327 continue;
328 }
329 if in_section {
330 break;
331 }
332 }
333 if in_section {
334 out.push_str(line);
335 out.push('\n');
336 }
337 }
338 out
339}
340
341pub fn validate_tasks_file(ito_path: &Path, change_id: &str) -> CoreResult<Vec<ValidationIssue>> {
343 use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file, tasks_path};
344
345 let path = tasks_path(ito_path, change_id);
346 let report_path = format!("changes/{change_id}/tasks.md");
347
348 let contents = match ito_common::io::read_to_string(&path) {
349 Ok(c) => c,
350 Err(e) => {
351 return Ok(vec![error(
352 &report_path,
353 format!("Failed to read {report_path}: {e}"),
354 )]);
355 }
356 };
357
358 let parsed = parse_tasks_tracking_file(&contents);
359 let mut issues = Vec::new();
360 for d in &parsed.diagnostics {
361 let level = match d.level {
362 DiagnosticLevel::Error => LEVEL_ERROR,
363 DiagnosticLevel::Warning => LEVEL_WARNING,
364 };
365 issues.push(ValidationIssue {
366 path: report_path.clone(),
367 level: level.to_string(),
368 message: d.message.clone(),
369 line: d.line.map(|l| l as u32),
370 column: None,
371 metadata: None,
372 });
373 }
374 Ok(issues)
375}