1use crate::error_bridge::IntoCoreResult;
9use crate::errors::{CoreError, CoreResult};
10use serde_json::Value;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15use ito_domain::tasks::{DiagnosticLevel, TaskRepository as DomainTaskRepository};
16
17use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
18
19#[derive(Debug, Clone)]
21pub struct ValidationResult {
22 pub success: bool,
24 pub message: String,
26 pub output: Option<String>,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ValidationStep {
33 TaskStatus,
35 ProjectCheck,
37 ExtraCommand,
39}
40
41pub fn check_task_completion(
45 task_repo: &impl DomainTaskRepository,
46 change_id: &str,
47) -> CoreResult<ValidationResult> {
48 let repo = task_repo;
49 let parsed = repo.load_tasks(change_id).into_core()?;
50
51 if parsed.progress.total == 0 {
52 return Ok(ValidationResult {
53 success: true,
54 message: "No tasks configured; skipping task status validation".to_string(),
55 output: None,
56 });
57 }
58
59 let mut parse_errors: usize = 0;
60 for diagnostic in &parsed.diagnostics {
61 if diagnostic.level == DiagnosticLevel::Error {
62 parse_errors += 1;
63 }
64 }
65
66 let remaining = parsed.progress.remaining;
67 let success = remaining == 0 && parse_errors == 0;
68
69 let mut lines: Vec<String> = Vec::new();
70 lines.push(format!("Total: {}", parsed.progress.total));
71 lines.push(format!("Complete: {}", parsed.progress.complete));
72 lines.push(format!("Shelved: {}", parsed.progress.shelved));
73 lines.push(format!("In-progress: {}", parsed.progress.in_progress));
74 lines.push(format!("Pending: {}", parsed.progress.pending));
75 lines.push(format!("Remaining: {}", parsed.progress.remaining));
76
77 if parse_errors > 0 {
78 lines.push(format!("Parse errors: {parse_errors}"));
79 }
80
81 if !success {
82 lines.push(String::new());
83 lines.push("Incomplete tasks:".to_string());
84 for t in &parsed.tasks {
85 if t.status.is_done() {
86 continue;
87 }
88 lines.push(format!(
89 "- {id} ({status}) {name}",
90 id = t.id,
91 status = t.status.as_enhanced_label(),
92 name = t.name
93 ));
94 }
95 }
96
97 let output = Some(lines.join("\n"));
98
99 let message = if success {
100 "All tasks are complete or shelved".to_string()
101 } else {
102 "Tasks remain pending or in-progress".to_string()
103 };
104
105 Ok(ValidationResult {
106 success,
107 message,
108 output,
109 })
110}
111
112pub fn run_project_validation(ito_path: &Path, timeout: Duration) -> CoreResult<ValidationResult> {
116 let project_root = ito_path.parent().unwrap_or_else(|| Path::new("."));
117 let commands = discover_project_validation_commands(project_root, ito_path)?;
118
119 if commands.is_empty() {
120 return Ok(ValidationResult {
121 success: true,
122 message: "Warning: no project validation configured; skipping".to_string(),
123 output: None,
124 });
125 }
126
127 let mut combined: Vec<String> = Vec::new();
128 for cmd in commands {
129 let out = run_shell_with_timeout(project_root, &cmd, timeout)?;
130 combined.push(out.render());
131 if !out.success {
132 return Ok(ValidationResult {
133 success: false,
134 message: format!("Project validation failed: `{cmd}`"),
135 output: Some(combined.join("\n\n")),
136 });
137 }
138 }
139
140 Ok(ValidationResult {
141 success: true,
142 message: "Project validation passed".to_string(),
143 output: Some(combined.join("\n\n")),
144 })
145}
146
147pub fn run_extra_validation(
149 project_root: &Path,
150 command: &str,
151 timeout: Duration,
152) -> CoreResult<ValidationResult> {
153 let out = run_shell_with_timeout(project_root, command, timeout)?;
154 Ok(ValidationResult {
155 success: out.success,
156 message: if out.success {
157 format!("Extra validation passed: `{command}`")
158 } else {
159 format!("Extra validation failed: `{command}`")
160 },
161 output: Some(out.render()),
162 })
163}
164
165fn discover_project_validation_commands(
166 project_root: &Path,
167 ito_path: &Path,
168) -> CoreResult<Vec<String>> {
169 let candidates: Vec<(ProjectSource, PathBuf)> = vec![
170 (ProjectSource::RepoJson, project_root.join("ito.json")),
171 (ProjectSource::ItoConfigJson, ito_path.join("config.json")),
172 (ProjectSource::AgentsMd, project_root.join("AGENTS.md")),
173 (ProjectSource::ClaudeMd, project_root.join("CLAUDE.md")),
174 ];
175
176 for (source, path) in candidates {
177 if !path.exists() {
178 continue;
179 }
180 let contents = fs::read_to_string(&path)
181 .map_err(|e| CoreError::io(format!("Failed to read {}", path.display()), e))?;
182 let commands = match source {
183 ProjectSource::RepoJson | ProjectSource::ItoConfigJson => {
184 extract_commands_from_json_str(&contents)
185 }
186 ProjectSource::AgentsMd | ProjectSource::ClaudeMd => {
187 extract_commands_from_markdown(&contents)
188 }
189 };
190 if !commands.is_empty() {
191 return Ok(commands);
192 }
193 }
194
195 Ok(Vec::new())
196}
197
198#[derive(Debug, Clone, Copy)]
199enum ProjectSource {
200 RepoJson,
201 ItoConfigJson,
202 AgentsMd,
203 ClaudeMd,
204}
205
206fn extract_commands_from_json_str(contents: &str) -> Vec<String> {
207 let v: Value = match serde_json::from_str(contents) {
208 Ok(v) => v,
209 Err(_) => return Vec::new(),
210 };
211 extract_commands_from_json_value(&v)
212}
213
214fn extract_commands_from_json_value(v: &Value) -> Vec<String> {
215 let pointers = [
216 "/ralph/validationCommands",
217 "/ralph/validationCommand",
218 "/ralph/validation/commands",
219 "/ralph/validation/command",
220 "/validationCommands",
221 "/validationCommand",
222 "/project/validationCommands",
223 "/project/validationCommand",
224 "/project/validation/commands",
225 "/project/validation/command",
226 ];
227
228 for p in pointers {
229 if let Some(v) = v.pointer(p) {
230 let commands = normalize_commands_value(v);
231 if !commands.is_empty() {
232 return commands;
233 }
234 }
235 }
236
237 Vec::new()
238}
239
240fn normalize_commands_value(v: &Value) -> Vec<String> {
241 match v {
242 Value::String(s) => {
243 let s = s.trim();
244 if s.is_empty() {
245 Vec::new()
246 } else {
247 vec![s.to_string()]
248 }
249 }
250 Value::Array(items) => {
251 let mut out: Vec<String> = Vec::new();
252 for item in items {
253 if let Value::String(s) = item {
254 let s = s.trim();
255 if !s.is_empty() {
256 out.push(s.to_string());
257 }
258 }
259 }
260 out
261 }
262 Value::Null => Vec::new(),
263 Value::Bool(_b) => Vec::new(),
264 Value::Number(_n) => Vec::new(),
265 Value::Object(_obj) => Vec::new(),
266 }
267}
268
269fn extract_commands_from_markdown(contents: &str) -> Vec<String> {
270 let mut out: Vec<String> = Vec::new();
272 for line in contents.lines() {
273 let l = line.trim();
274 if l == "make check" || l == "make test" {
275 out.push(l.to_string());
276 }
277 }
278 out.dedup();
279 out
280}
281
282#[derive(Debug)]
283struct ShellRunOutput {
284 command: String,
285 success: bool,
286 exit_code: i32,
287 timed_out: bool,
288 stdout: String,
289 stderr: String,
290}
291
292impl ShellRunOutput {
293 fn render(&self) -> String {
294 let mut s = String::new();
295 s.push_str(&format!("Command: {}\n", self.command));
296 if self.timed_out {
297 s.push_str("Result: TIMEOUT\n");
298 } else if self.success {
299 s.push_str("Result: PASS\n");
300 } else {
301 s.push_str(&format!("Result: FAIL (exit {})\n", self.exit_code));
302 }
303 if !self.stdout.trim().is_empty() {
304 s.push_str("\nStdout:\n");
305 s.push_str(&truncate_for_context(&self.stdout, 12_000));
306 s.push('\n');
307 }
308 if !self.stderr.trim().is_empty() {
309 s.push_str("\nStderr:\n");
310 s.push_str(&truncate_for_context(&self.stderr, 12_000));
311 s.push('\n');
312 }
313 s
314 }
315}
316
317fn run_shell_with_timeout(cwd: &Path, cmd: &str, timeout: Duration) -> CoreResult<ShellRunOutput> {
337 let runner = SystemProcessRunner;
338 let request = ProcessRequest::new("sh")
339 .args(["-c", cmd])
340 .current_dir(cwd.to_path_buf());
341 let output = runner.run_with_timeout(&request, timeout).map_err(|e| {
342 CoreError::Process(format!("Failed to run validation command '{cmd}': {e}"))
343 })?;
344
345 Ok(ShellRunOutput {
346 command: cmd.to_string(),
347 success: output.success,
348 exit_code: output.exit_code,
349 timed_out: output.timed_out,
350 stdout: output.stdout,
351 stderr: output.stderr,
352 })
353}
354
355fn truncate_for_context(s: &str, max_bytes: usize) -> String {
356 if s.len() <= max_bytes {
357 return s.to_string();
358 }
359 let mut end = max_bytes;
362 while end > 0 && !s.is_char_boundary(end) {
363 end -= 1;
364 }
365 let mut out = s[..end].to_string();
366 out.push_str("\n... (truncated) ...");
367 out
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use std::fs;
374
375 fn write(path: &Path, contents: &str) {
376 if let Some(parent) = path.parent() {
377 fs::create_dir_all(parent).unwrap();
378 }
379 fs::write(path, contents).unwrap();
380 }
381
382 #[test]
383 fn task_completion_passes_when_no_tasks() {
384 let td = tempfile::tempdir().unwrap();
385 let ito = td.path().join(".ito");
386 fs::create_dir_all(&ito).unwrap();
387 let task_repo = crate::task_repository::FsTaskRepository::new(&ito);
388 let r = check_task_completion(&task_repo, "001-01_missing").unwrap();
389 assert!(r.success);
390 }
391
392 #[test]
393 fn task_completion_fails_when_remaining() {
394 let td = tempfile::tempdir().unwrap();
395 let ito = td.path().join(".ito");
396 fs::create_dir_all(ito.join("changes/001-01_test")).unwrap();
397 write(
398 &ito.join("changes/001-01_test/tasks.md"),
399 "# Tasks\n\n- [x] done\n- [ ] todo\n",
400 );
401 let task_repo = crate::task_repository::FsTaskRepository::new(&ito);
402 let r = check_task_completion(&task_repo, "001-01_test").unwrap();
403 assert!(!r.success);
404 }
405
406 #[test]
407 fn project_validation_discovers_commands_from_repo_json() {
408 let td = tempfile::tempdir().unwrap();
409 let project_root = td.path();
410 let ito = project_root.join(".ito");
411 fs::create_dir_all(&ito).unwrap();
412 write(
413 &project_root.join("ito.json"),
414 r#"{ "ralph": { "validationCommands": ["true"] } }"#,
415 );
416 let cmds = discover_project_validation_commands(project_root, &ito).unwrap();
417 assert_eq!(cmds, vec!["true".to_string()]);
418 }
419
420 #[test]
421 fn shell_timeout_is_failure() {
422 let td = tempfile::tempdir().unwrap();
423 let out =
424 run_shell_with_timeout(td.path(), "sleep 0.1", Duration::from_millis(50)).unwrap();
425 assert!(out.timed_out);
426 assert!(!out.success);
427 }
428
429 #[test]
430 fn extract_commands_from_markdown_finds_make_check() {
431 let markdown = "Some text\nmake check\nMore text";
432 let commands = extract_commands_from_markdown(markdown);
433 assert_eq!(commands, vec!["make check"]);
434 }
435
436 #[test]
437 fn extract_commands_from_markdown_finds_make_test() {
438 let markdown = "Some text\nmake test\nMore text";
439 let commands = extract_commands_from_markdown(markdown);
440 assert_eq!(commands, vec!["make test"]);
441 }
442
443 #[test]
444 fn extract_commands_from_markdown_ignores_other_lines() {
445 let markdown = "echo hello\nsome other text";
446 let commands = extract_commands_from_markdown(markdown);
447 assert!(commands.is_empty());
448 }
449
450 #[test]
451 fn normalize_commands_value_string() {
452 let value = Value::String("make test".to_string());
453 let commands = normalize_commands_value(&value);
454 assert_eq!(commands, vec!["make test"]);
455 }
456
457 #[test]
458 fn normalize_commands_value_array() {
459 let value = Value::Array(vec![
460 Value::String("make test".to_string()),
461 Value::String("make lint".to_string()),
462 ]);
463 let commands = normalize_commands_value(&value);
464 assert_eq!(commands, vec!["make test", "make lint"]);
465 }
466
467 #[test]
468 fn normalize_commands_value_null() {
469 let value = Value::Null;
470 let commands = normalize_commands_value(&value);
471 assert!(commands.is_empty());
472 }
473
474 #[test]
475 fn normalize_commands_value_non_string() {
476 let value = Value::Number(serde_json::Number::from(42));
477 let commands = normalize_commands_value(&value);
478 assert!(commands.is_empty());
479 }
480
481 #[test]
482 fn truncate_for_context_short_unchanged() {
483 let short_text = "a".repeat(1000);
484 let result = truncate_for_context(&short_text, 12_000);
485 assert_eq!(result, short_text);
486 }
487
488 #[test]
489 fn truncate_for_context_long_truncated() {
490 let long_text = "a".repeat(15_000);
491 let result = truncate_for_context(&long_text, 12_000);
492 assert!(result.len() < long_text.len());
493 assert!(result.contains("... (truncated) ..."));
494 }
495
496 #[test]
497 fn truncate_for_context_multibyte_utf8() {
498 let text = "\u{65E5}".repeat(5_000); let result = truncate_for_context(&text, 12_000);
501 assert!(result.contains("... (truncated) ..."));
502 assert!(!result.contains('\u{FFFD}'));
504 }
505
506 #[test]
507 fn extract_commands_from_json_multiple_paths() {
508 let json_str = r#"{ "ralph": { "validationCommands": ["make check"] } }"#;
509 let value: Value = serde_json::from_str(json_str).unwrap();
510 let commands = extract_commands_from_json_value(&value);
511 assert_eq!(commands, vec!["make check"]);
512
513 let json_str2 = r#"{ "project": { "validation": { "commands": ["make test"] } } }"#;
514 let value2: Value = serde_json::from_str(json_str2).unwrap();
515 let commands2 = extract_commands_from_json_value(&value2);
516 assert_eq!(commands2, vec!["make test"]);
517
518 let json_str3 = r#"{ "validationCommands": ["make lint"] }"#;
519 let value3: Value = serde_json::from_str(json_str3).unwrap();
520 let commands3 = extract_commands_from_json_value(&value3);
521 assert_eq!(commands3, vec!["make lint"]);
522 }
523
524 #[test]
525 fn run_extra_validation_success() {
526 let td = tempfile::tempdir().unwrap();
527 let result = run_extra_validation(td.path(), "true", Duration::from_secs(10)).unwrap();
528 assert!(result.success);
529 assert!(result.message.contains("passed"));
530 }
531
532 #[test]
533 fn run_extra_validation_failure() {
534 let td = tempfile::tempdir().unwrap();
535 let result = run_extra_validation(td.path(), "false", Duration::from_secs(10)).unwrap();
536 assert!(!result.success);
537 assert!(result.message.contains("failed"));
538 }
539
540 #[test]
541 fn discover_commands_priority_ito_json_first() {
542 let td = tempfile::tempdir().unwrap();
543 let project_root = td.path();
544 let ito_path = project_root.join(".ito");
545 fs::create_dir_all(&ito_path).unwrap();
546
547 write(
548 &project_root.join("ito.json"),
549 r#"{"ralph":{"validationCommands":["make ito-check"]}}"#,
550 );
551 write(&project_root.join("AGENTS.md"), "make check");
552
553 let commands = discover_project_validation_commands(project_root, &ito_path).unwrap();
554 assert_eq!(commands, vec!["make ito-check"]);
555 }
556
557 #[test]
558 fn discover_commands_falls_back_to_agents_md() {
559 let td = tempfile::tempdir().unwrap();
560 let project_root = td.path();
561 let ito_path = project_root.join(".ito");
562 fs::create_dir_all(&ito_path).unwrap();
563
564 write(&project_root.join("AGENTS.md"), "make test");
565
566 let commands = discover_project_validation_commands(project_root, &ito_path).unwrap();
567 assert_eq!(commands, vec!["make test"]);
568 }
569
570 #[test]
571 fn discover_commands_falls_back_to_claude_md() {
572 let td = tempfile::tempdir().unwrap();
573 let project_root = td.path();
574 let ito_path = project_root.join(".ito");
575 fs::create_dir_all(&ito_path).unwrap();
576
577 write(&project_root.join("CLAUDE.md"), "make check");
578
579 let commands = discover_project_validation_commands(project_root, &ito_path).unwrap();
580 assert_eq!(commands, vec!["make check"]);
581 }
582
583 #[test]
584 fn discover_commands_ito_config_json() {
585 let td = tempfile::tempdir().unwrap();
586 let project_root = td.path();
587 let ito_path = project_root.join(".ito");
588 fs::create_dir_all(&ito_path).unwrap();
589
590 write(
591 &ito_path.join("config.json"),
592 r#"{"validationCommand": "make lint"}"#,
593 );
594
595 let commands = discover_project_validation_commands(project_root, &ito_path).unwrap();
596 assert_eq!(commands, vec!["make lint"]);
597 }
598
599 #[test]
600 fn discover_commands_returns_empty_when_nothing_configured() {
601 let td = tempfile::tempdir().unwrap();
602 let project_root = td.path();
603 let ito_path = project_root.join(".ito");
604 fs::create_dir_all(&ito_path).unwrap();
605
606 let commands = discover_project_validation_commands(project_root, &ito_path).unwrap();
607 assert!(commands.is_empty());
608 }
609}