Skip to main content

ito_core/ralph/
validation.rs

1//! Completion validation for the Ralph loop.
2//!
3//! These helpers are invoked when a completion promise is detected. They verify:
4//! - Ito task status (all tasks complete or shelved)
5//! - Project validation commands (build/tests/lints)
6//! - Optional extra validation command provided via CLI
7
8use 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/// Result of one validation step.
20#[derive(Debug, Clone)]
21pub struct ValidationResult {
22    /// Whether the step succeeded.
23    pub success: bool,
24    /// Human-readable message summary.
25    pub message: String,
26    /// Optional verbose output (stdout/stderr, details).
27    pub output: Option<String>,
28}
29
30/// Which validation step produced a [`ValidationResult`].
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ValidationStep {
33    /// Ito task status validation.
34    TaskStatus,
35    /// Project validation commands.
36    ProjectCheck,
37    /// Extra command provided via `--validation-command`.
38    ExtraCommand,
39}
40
41/// Check that all tasks for `change_id` are complete or shelved.
42///
43/// Missing tasks file is treated as success.
44pub 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
112/// Run project validation commands discovered from configuration sources.
113///
114/// If no validation is configured, returns success with a warning message.
115pub 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
147/// Run an extra validation command provided explicitly by the user.
148pub 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    // Heuristic: accept `make check` / `make test` lines anywhere.
271    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
317/// Run a shell command in the given working directory with a timeout and capture its result.
318///
319/// Executes `sh -c <cmd>` in `cwd` and returns a `ShellRunOutput` describing the executed command,
320/// whether it succeeded, the exit code, whether it timed out, and captured `stdout`/`stderr`.
321///
322/// # Errors
323///
324/// Returns a `CoreError::Process` if the command could not be executed.
325///
326/// # Examples
327///
328/// ```ignore
329/// use std::path::Path;
330/// use std::time::Duration;
331///
332/// let out = run_shell_with_timeout(Path::new("."), "echo hello", Duration::from_secs(5)).unwrap();
333/// assert!(out.success);
334/// assert!(out.stdout.contains("hello"));
335/// ```
336fn 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    // Find a valid UTF-8 boundary at or before max_bytes to avoid panicking
360    // on multi-byte characters.
361    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        // Each CJK character is 3 bytes in UTF-8.
499        let text = "\u{65E5}".repeat(5_000); // 15,000 bytes
500        let result = truncate_for_context(&text, 12_000);
501        assert!(result.contains("... (truncated) ..."));
502        // The truncated portion must be valid UTF-8 (no panic, no replacement chars).
503        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}