git_lore/lore/
validation.rs1use 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}