Skip to main content

git_lore/lore/
validation.rs

1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{bail, Context, Result};
5use serde::{Deserialize, Serialize};
6
7use super::{AtomState, LoreAtom};
8
9const NARRATIVE_PREFIXES: &[&str] = &[
10    "verify that",
11    "ensure that",
12    "check that",
13    "confirm that",
14    "make sure",
15    "make sure that",
16    "validate that",
17    "describe how",
18    "explain how",
19    "the script should",
20    "this script should",
21    "the command should",
22    "this command should",
23    "comprueba que",
24    "verifica que",
25    "asegúrate de",
26    "asegure que",
27    "asegúrese de",
28    "confirma que",
29    "describe cómo",
30    "explica cómo",
31    "escribe un",
32    "escribe una",
33    "valida que",
34];
35
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ValidationIssue {
38    pub atom_id: String,
39    pub command: String,
40    pub reason: String,
41}
42
43pub fn validate_script(script: &str) -> Result<()> {
44    let script = script.trim();
45
46    if script.is_empty() {
47        bail!("validation script cannot be empty");
48    }
49
50    if script.contains('\n') || script.contains('\r') || script.contains('\0') {
51        bail!("validation script must be a single-line literal shell command");
52    }
53
54    if looks_like_narrative(script) {
55        bail!("validation script looks like natural language; provide a literal shell command instead");
56    }
57
58    Ok(())
59}
60
61pub fn scan_atoms(workspace_root: &Path, atoms: &[LoreAtom]) -> Vec<ValidationIssue> {
62    let mut issues = Vec::new();
63
64    for atom in atoms.iter().filter(|atom| atom.state != AtomState::Deprecated) {
65        let Some(script) = atom.validation_script.as_deref().map(str::trim) else {
66            continue;
67        };
68
69        if script.is_empty() {
70            continue;
71        }
72
73        if let Err(reason) = validate_script(script) {
74            issues.push(ValidationIssue {
75                atom_id: atom.id.clone(),
76                command: script.to_string(),
77                reason: reason.to_string(),
78            });
79            continue;
80        }
81
82        match run_script(workspace_root, atom, script) {
83            Ok(()) => {}
84            Err(reason) => issues.push(ValidationIssue {
85                atom_id: atom.id.clone(),
86                command: script.to_string(),
87                reason: reason.to_string(),
88            }),
89        }
90    }
91
92    issues
93}
94
95fn run_script(workspace_root: &Path, atom: &LoreAtom, script: &str) -> Result<()> {
96    let output = Command::new("/bin/sh")
97        .arg("-lc")
98        .arg(script)
99        .current_dir(workspace_root)
100        .env("GIT_LORE_ATOM_ID", &atom.id)
101        .env("GIT_LORE_ATOM_KIND", format!("{:?}", atom.kind))
102        .env("GIT_LORE_ATOM_STATE", format!("{:?}", atom.state))
103        .env("GIT_LORE_ATOM_TITLE", &atom.title)
104        .env("GIT_LORE_ATOM_SCOPE", atom.scope.as_deref().unwrap_or(""))
105        .env(
106            "GIT_LORE_ATOM_PATH",
107            atom.path.as_ref().map(|path| path.display().to_string()).unwrap_or_default(),
108        )
109        .output()
110        .with_context(|| format!("failed to run validation script for atom {}", atom.id))?;
111
112    if output.status.success() {
113        return Ok(());
114    }
115
116    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
117    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
118    let details = if stderr.is_empty() && stdout.is_empty() {
119        format!("validation script exited with status {}", output.status)
120    } else if stderr.is_empty() {
121        format!("validation script failed: {stdout}")
122    } else if stdout.is_empty() {
123        format!("validation script failed: {stderr}")
124    } else {
125        format!("validation script failed: {stderr}; {stdout}")
126    };
127
128    Err(anyhow::anyhow!(details))
129}
130
131fn looks_like_narrative(script: &str) -> bool {
132    let lowered = script.to_ascii_lowercase();
133
134    NARRATIVE_PREFIXES
135        .iter()
136        .any(|prefix| lowered.starts_with(prefix))
137}
138
139#[cfg(test)]
140mod tests {
141    use std::fs;
142    use std::path::PathBuf;
143
144    use uuid::Uuid;
145
146    use crate::lore::{AtomState, LoreAtom, LoreKind, Workspace};
147
148    use super::{scan_atoms, validate_script};
149
150    #[test]
151    fn validate_script_accepts_literal_shell_command() {
152        validate_script("cargo test --quiet").unwrap();
153    }
154
155    #[test]
156    fn validate_script_rejects_narrative_text() {
157        let error = validate_script("Verify that external writes are blocked before commit")
158            .unwrap_err();
159
160        assert!(error.to_string().contains("literal shell command"));
161    }
162
163    #[test]
164    fn scan_atoms_reports_narrative_scripts_without_shelling_out() {
165        let temp_root = std::env::temp_dir().join(format!(
166            "git-lore-validation-test-{}",
167            Uuid::new_v4()
168        ));
169        fs::create_dir_all(&temp_root).unwrap();
170
171        let atom = LoreAtom::new(
172            LoreKind::Decision,
173            AtomState::Proposed,
174            "Guard writes".to_string(),
175            None,
176            None,
177            Some(PathBuf::from("src/lib.rs")),
178        )
179        .with_validation_script(Some(
180            "Verify that external writes are blocked before commit".to_string(),
181        ));
182
183        let issues = scan_atoms(&temp_root, &[atom]);
184
185        assert_eq!(issues.len(), 1);
186        assert_eq!(issues[0].command, "Verify that external writes are blocked before commit");
187        assert!(issues[0].reason.contains("literal shell command"));
188    }
189
190    #[test]
191    fn workspace_rejects_narrative_validation_script_on_record() {
192        let temp_root = std::env::temp_dir().join(format!(
193            "git-lore-validation-record-test-{}",
194            Uuid::new_v4()
195        ));
196        fs::create_dir_all(&temp_root).unwrap();
197        let workspace = Workspace::init(&temp_root).unwrap();
198
199        let atom = LoreAtom::new(
200            LoreKind::Decision,
201            AtomState::Proposed,
202            "Guard writes".to_string(),
203            None,
204            Some("sync".to_string()),
205            None,
206        )
207        .with_validation_script(Some(
208            "Verify that external writes are blocked before commit".to_string(),
209        ));
210
211        let error = workspace.record_atom(atom).unwrap_err();
212        assert!(error.to_string().contains("literal shell command"));
213    }
214}