1use std::path::Path;
2use std::process::Command;
3
4use crate::config::{Config, RuleConfig};
5use crate::diagnostic::Diagnostic;
6use crate::graph::Graph;
7
8pub fn run_script_rules(graph: &Graph, root: &Path, config: &Config) -> Vec<Diagnostic> {
19 let mut diagnostics = Vec::new();
20 let config_dir = config.config_dir.as_deref().unwrap_or(root);
21
22 for (rule_name, rule_config) in config.script_rules() {
23 match run_one(rule_name, rule_config, graph, root, config_dir) {
24 Ok(mut results) => diagnostics.append(&mut results),
25 Err(e) => {
26 eprintln!("warn: script rule \"{rule_name}\" failed: {e}");
27 diagnostics.push(Diagnostic {
29 rule: rule_name.to_string(),
30 severity: rule_config.severity,
31 message: format!("script rule failed: {e}"),
32 fix: Some(format!(
33 "script rule \"{rule_name}\" failed to execute — check the command path and script"
34 )),
35 ..Default::default()
36 });
37 }
38 }
39 }
40
41 diagnostics
42}
43
44fn run_one(
45 rule_name: &str,
46 rule_config: &RuleConfig,
47 graph: &Graph,
48 root: &Path,
49 config_dir: &Path,
50) -> anyhow::Result<Vec<Diagnostic>> {
51 let command = rule_config
52 .command
53 .as_deref()
54 .ok_or_else(|| anyhow::anyhow!("rule \"{rule_name}\" has no command"))?;
55
56 let graph_json = build_graph_json(graph);
58
59 let parts: Vec<&str> = command.split_whitespace().collect();
61 if parts.is_empty() {
62 anyhow::bail!("empty command");
63 }
64
65 let cmd = if parts[0].starts_with("./") || parts[0].starts_with("../") {
67 config_dir.join(parts[0]).to_string_lossy().to_string()
68 } else {
69 parts[0].to_string()
70 };
71
72 let output = Command::new(&cmd)
73 .args(&parts[1..])
74 .current_dir(root)
75 .stdin(std::process::Stdio::piped())
76 .stdout(std::process::Stdio::piped())
77 .stderr(std::process::Stdio::piped())
78 .spawn()
79 .and_then(|mut child| {
80 use std::io::Write;
81 if let Some(ref mut stdin) = child.stdin {
82 let _ = stdin.write_all(graph_json.as_bytes());
83 }
84 child.wait_with_output()
85 })?;
86
87 if !output.status.success() {
88 let stderr = String::from_utf8_lossy(&output.stderr);
89 anyhow::bail!("exited with {}: {}", output.status, stderr.trim());
90 }
91
92 let stdout = String::from_utf8_lossy(&output.stdout);
93 let mut diagnostics = Vec::new();
94
95 for line in stdout.lines() {
96 let line = line.trim();
97 if line.is_empty() {
98 continue;
99 }
100
101 match serde_json::from_str::<CustomDiagnostic>(line) {
102 Ok(cd) => {
103 diagnostics.push(Diagnostic {
104 rule: rule_name.to_string(),
105 severity: rule_config.severity,
106 message: cd.message,
107 source: cd.source,
108 target: cd.target,
109 node: cd.node,
110 fix: cd.fix,
111 ..Default::default()
112 });
113 }
114 Err(e) => {
115 eprintln!("warn: script rule \"{rule_name}\": failed to parse output line: {e}");
116 }
117 }
118 }
119
120 Ok(diagnostics)
121}
122
123#[derive(serde::Deserialize)]
124struct CustomDiagnostic {
125 message: String,
126 #[serde(default)]
127 source: Option<String>,
128 #[serde(default)]
129 target: Option<String>,
130 #[serde(default)]
131 node: Option<String>,
132 #[serde(default)]
133 fix: Option<String>,
134}
135
136pub(crate) fn build_graph_json(graph: &Graph) -> String {
137 let mut nodes = serde_json::Map::new();
138 for (path, node) in &graph.nodes {
139 let mut meta = serde_json::Map::new();
140 meta.insert("type".into(), serde_json::json!(node.node_type));
141 if let Some(h) = &node.hash {
142 meta.insert("hash".into(), serde_json::json!(h));
143 }
144 nodes.insert(path.clone(), serde_json::json!({ "metadata": meta }));
145 }
146
147 let edges: Vec<serde_json::Value> = graph
148 .edges
149 .iter()
150 .filter(|e| graph.nodes.contains_key(&e.target))
151 .map(|e| {
152 serde_json::json!({
153 "source": e.source,
154 "target": e.target,
155 "relation": e.edge_type,
156 })
157 })
158 .collect();
159
160 let output = serde_json::json!({
161 "graph": {
162 "directed": true,
163 "nodes": nodes,
164 "edges": edges,
165 }
166 });
167
168 serde_json::to_string(&output).unwrap()
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::graph::{Edge, EdgeType, Graph, Node, NodeType};
175 use std::fs;
176 use tempfile::TempDir;
177
178 fn make_graph() -> Graph {
179 let mut g = Graph::new();
180 g.add_node(Node {
181 path: "index.md".into(),
182 node_type: NodeType::Source,
183 hash: Some("b3:aaa".into()),
184 graph: None,
185 });
186 g.add_node(Node {
187 path: "setup.md".into(),
188 node_type: NodeType::Source,
189 hash: Some("b3:bbb".into()),
190 graph: None,
191 });
192 g.add_edge(Edge {
193 source: "index.md".into(),
194 target: "setup.md".into(),
195 edge_type: EdgeType::new("markdown", "inline"),
196 synthetic: false,
197 });
198 g
199 }
200
201 #[test]
202 fn runs_custom_script() {
203 let dir = TempDir::new().unwrap();
204
205 let script = dir.path().join("my-rule.sh");
207 fs::write(
208 &script,
209 "#!/bin/sh\necho '{\"message\": \"custom issue\", \"node\": \"index.md\", \"fix\": \"do something\"}'\n",
210 )
211 .unwrap();
212
213 #[cfg(unix)]
214 {
215 use std::os::unix::fs::PermissionsExt;
216 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
217 }
218
219 let config = RuleConfig {
220 command: Some(script.to_string_lossy().to_string()),
221 severity: crate::config::RuleSeverity::Warn,
222 ignore: Vec::new(),
223 timeout: None,
224 ignore_compiled: None,
225 };
226
227 let graph = make_graph();
228 let diagnostics = run_one("my-rule", &config, &graph, dir.path(), dir.path()).unwrap();
229
230 assert_eq!(diagnostics.len(), 1);
231 assert_eq!(diagnostics[0].rule, "my-rule");
232 assert_eq!(diagnostics[0].message, "custom issue");
233 assert_eq!(diagnostics[0].node.as_deref(), Some("index.md"));
234 assert_eq!(diagnostics[0].fix.as_deref(), Some("do something"));
235 }
236
237 #[test]
238 fn handles_failing_script() {
239 let dir = TempDir::new().unwrap();
240 let script = dir.path().join("bad-rule.sh");
241 fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
242
243 #[cfg(unix)]
244 {
245 use std::os::unix::fs::PermissionsExt;
246 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
247 }
248
249 let config = RuleConfig {
250 command: Some(script.to_string_lossy().to_string()),
251 severity: crate::config::RuleSeverity::Warn,
252 ignore: Vec::new(),
253 timeout: None,
254 ignore_compiled: None,
255 };
256
257 let graph = make_graph();
258 let result = run_one("bad-rule", &config, &graph, dir.path(), dir.path());
259 assert!(result.is_err());
260 }
261}