1use std::path::Path;
2use std::process::Command;
3
4use crate::analyses::EnrichedGraph;
5use crate::config::{Config, RuleConfig};
6use crate::diagnostic::Diagnostic;
7
8pub fn run_custom_rules(enriched: &EnrichedGraph, root: &Path, config: &Config) -> Vec<Diagnostic> {
20 let mut diagnostics = Vec::new();
21 let config_dir = config.config_dir.as_deref().unwrap_or(root);
22
23 for (rule_name, rule_config) in config.custom_rules() {
24 match run_one(rule_name, rule_config, enriched, root, config_dir) {
25 Ok(mut results) => diagnostics.append(&mut results),
26 Err(e) => {
27 eprintln!("warn: custom rule \"{rule_name}\" failed: {e}");
28 diagnostics.push(Diagnostic {
30 rule: rule_name.to_string(),
31 severity: rule_config.severity,
32 message: format!("custom rule failed: {e}"),
33 fix: Some(format!(
34 "custom rule \"{rule_name}\" failed to execute — check the command path and script"
35 )),
36 ..Default::default()
37 });
38 }
39 }
40 }
41
42 diagnostics
43}
44
45pub fn run_one(
46 rule_name: &str,
47 rule_config: &RuleConfig,
48 enriched: &EnrichedGraph,
49 root: &Path,
50 config_dir: &Path,
51) -> anyhow::Result<Vec<Diagnostic>> {
52 let command = rule_config
53 .command
54 .as_deref()
55 .ok_or_else(|| anyhow::anyhow!("rule \"{rule_name}\" has no command"))?;
56
57 let graph_json = build_enriched_json(enriched, rule_config.options.as_ref());
59
60 let parts: Vec<&str> = command.split_whitespace().collect();
62 if parts.is_empty() {
63 anyhow::bail!("empty command");
64 }
65
66 let cmd = if parts[0].starts_with("./") || parts[0].starts_with("../") {
68 config_dir.join(parts[0]).to_string_lossy().to_string()
69 } else {
70 parts[0].to_string()
71 };
72
73 let output = Command::new(&cmd)
74 .args(&parts[1..])
75 .current_dir(root)
76 .stdin(std::process::Stdio::piped())
77 .stdout(std::process::Stdio::piped())
78 .stderr(std::process::Stdio::piped())
79 .spawn()
80 .and_then(|mut child| {
81 use std::io::Write;
82 if let Some(ref mut stdin) = child.stdin {
83 let _ = stdin.write_all(graph_json.as_bytes());
84 }
85 child.wait_with_output()
86 })?;
87
88 if !output.status.success() {
89 let stderr = String::from_utf8_lossy(&output.stderr);
90 anyhow::bail!("exited with {}: {}", output.status, stderr.trim());
91 }
92
93 let stdout = String::from_utf8_lossy(&output.stdout);
94 let mut diagnostics = Vec::new();
95
96 for line in stdout.lines() {
97 let line = line.trim();
98 if line.is_empty() {
99 continue;
100 }
101
102 match serde_json::from_str::<CustomDiagnostic>(line) {
103 Ok(cd) => {
104 diagnostics.push(Diagnostic {
105 rule: rule_name.to_string(),
106 severity: rule_config.severity,
107 message: cd.message,
108 source: cd.source,
109 target: cd.target,
110 node: cd.node,
111 fix: cd.fix,
112 ..Default::default()
113 });
114 }
115 Err(e) => {
116 eprintln!("warn: custom rule \"{rule_name}\": failed to parse output line: {e}");
117 }
118 }
119 }
120
121 Ok(diagnostics)
122}
123
124#[derive(serde::Deserialize)]
125struct CustomDiagnostic {
126 message: String,
127 #[serde(default)]
128 source: Option<String>,
129 #[serde(default)]
130 target: Option<String>,
131 #[serde(default)]
132 node: Option<String>,
133 #[serde(default)]
134 fix: Option<String>,
135}
136
137fn build_enriched_json(enriched: &EnrichedGraph, options: Option<&toml::Value>) -> String {
142 let graph = &enriched.graph;
143
144 let mut nodes = serde_json::Map::new();
145 for (path, node) in &graph.nodes {
146 let mut meta = serde_json::Map::new();
147 meta.insert("type".into(), serde_json::json!(node.node_type));
148 if let Some(h) = &node.hash {
149 meta.insert("hash".into(), serde_json::json!(h));
150 }
151 nodes.insert(path.clone(), serde_json::json!({ "metadata": meta }));
152 }
153
154 let edges: Vec<serde_json::Value> = graph
155 .edges
156 .iter()
157 .filter(|e| graph.nodes.contains_key(&e.target))
158 .map(|e| {
159 let mut edge = serde_json::json!({
160 "source": e.source,
161 "target": e.target,
162 "parser": e.parser,
163 });
164 if let Some(ref r) = e.link {
165 edge["link"] = serde_json::json!(r);
166 }
167 edge
168 })
169 .collect();
170
171 let analyses = serde_json::json!({
172 "betweenness": enriched.betweenness,
173 "bridges": enriched.bridges,
174 "change_propagation": enriched.change_propagation,
175 "connected_components": enriched.connected_components,
176 "degree": enriched.degree,
177 "depth": enriched.depth,
178 "graph_boundaries": enriched.graph_boundaries,
179 "graph_stats": enriched.graph_stats,
180 "impact_radius": enriched.impact_radius,
181 "pagerank": enriched.pagerank,
182 "scc": enriched.scc,
183 "transitive_reduction": enriched.transitive_reduction,
184 });
185
186 let output = serde_json::json!({
187 "graph": {
188 "directed": true,
189 "nodes": nodes,
190 "edges": edges,
191 "analyses": analyses,
192 },
193 "options": options.unwrap_or(&toml::Value::Table(Default::default())),
194 });
195
196 serde_json::to_string(&output).unwrap()
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::analyses::enrich_graph;
203 use crate::graph::{Edge, Graph, Node, NodeType};
204 use std::collections::HashMap;
205 use std::fs;
206 use tempfile::TempDir;
207
208 fn make_enriched(dir: &Path) -> EnrichedGraph {
209 let mut g = Graph::new();
210 g.add_node(Node {
211 path: "index.md".into(),
212 node_type: NodeType::File,
213 hash: Some("b3:aaa".into()),
214 graph: None,
215 is_graph: false,
216 metadata: HashMap::new(),
217 included: true,
218 });
219 g.add_node(Node {
220 path: "setup.md".into(),
221 node_type: NodeType::File,
222 hash: Some("b3:bbb".into()),
223 graph: None,
224 is_graph: false,
225 metadata: HashMap::new(),
226 included: true,
227 });
228 g.add_edge(Edge {
229 source: "index.md".into(),
230 target: "setup.md".into(),
231 link: None,
232 parser: "markdown".into(),
233 });
234 let config = crate::config::Config {
235 include: vec!["*.md".into()],
236 exclude: vec![],
237 interface: None,
238 parsers: std::collections::HashMap::new(),
239 rules: std::collections::HashMap::new(),
240 config_dir: None,
241 };
242 enrich_graph(g, dir, &config, None)
243 }
244
245 #[test]
246 fn runs_custom_script() {
247 let dir = TempDir::new().unwrap();
248
249 let script = dir.path().join("my-rule.sh");
251 fs::write(
252 &script,
253 "#!/bin/sh\necho '{\"message\": \"custom issue\", \"node\": \"index.md\", \"fix\": \"do something\"}'\n",
254 )
255 .unwrap();
256
257 #[cfg(unix)]
258 {
259 use std::os::unix::fs::PermissionsExt;
260 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
261 }
262
263 let config = RuleConfig {
264 command: Some(script.to_string_lossy().to_string()),
265 severity: crate::config::RuleSeverity::Warn,
266 files: Vec::new(),
267 ignore: Vec::new(),
268 parsers: Vec::new(),
269 options: None,
270 files_compiled: None,
271 ignore_compiled: None,
272 };
273
274 let enriched = make_enriched(dir.path());
275 let diagnostics = run_one("my-rule", &config, &enriched, dir.path(), dir.path()).unwrap();
276
277 assert_eq!(diagnostics.len(), 1);
278 assert_eq!(diagnostics[0].rule, "my-rule");
279 assert_eq!(diagnostics[0].message, "custom issue");
280 assert_eq!(diagnostics[0].node.as_deref(), Some("index.md"));
281 assert_eq!(diagnostics[0].fix.as_deref(), Some("do something"));
282 }
283
284 #[test]
285 fn handles_failing_script() {
286 let dir = TempDir::new().unwrap();
287 let script = dir.path().join("bad-rule.sh");
288 fs::write(&script, "#!/bin/sh\nexit 1\n").unwrap();
289
290 #[cfg(unix)]
291 {
292 use std::os::unix::fs::PermissionsExt;
293 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
294 }
295
296 let config = RuleConfig {
297 command: Some(script.to_string_lossy().to_string()),
298 severity: crate::config::RuleSeverity::Warn,
299 files: Vec::new(),
300 ignore: Vec::new(),
301 parsers: Vec::new(),
302 options: None,
303 files_compiled: None,
304 ignore_compiled: None,
305 };
306
307 let enriched = make_enriched(dir.path());
308 let result = run_one("bad-rule", &config, &enriched, dir.path(), dir.path());
309 assert!(result.is_err());
310 }
311
312 #[test]
313 fn resolves_command_relative_to_config_dir() {
314 let dir = TempDir::new().unwrap();
315
316 let config_dir = dir.path();
318 let root = dir.path().join("docs");
319 fs::create_dir_all(&root).unwrap();
320
321 let scripts_dir = config_dir.join("scripts");
323 fs::create_dir_all(&scripts_dir).unwrap();
324 let script = scripts_dir.join("check.sh");
325 fs::write(
326 &script,
327 "#!/bin/sh\necho '{\"message\": \"found issue\", \"node\": \"index.md\"}'\n",
328 )
329 .unwrap();
330
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
335 }
336
337 let config = RuleConfig {
338 command: Some("./scripts/check.sh".to_string()),
339 severity: crate::config::RuleSeverity::Warn,
340 files: Vec::new(),
341 ignore: Vec::new(),
342 parsers: Vec::new(),
343 options: None,
344 files_compiled: None,
345 ignore_compiled: None,
346 };
347
348 let enriched = make_enriched(dir.path());
349 let diagnostics = run_one("my-rule", &config, &enriched, &root, config_dir).unwrap();
351
352 assert_eq!(diagnostics.len(), 1);
353 assert_eq!(diagnostics[0].message, "found issue");
354 }
355
356 #[test]
357 fn passes_options_to_script() {
358 let dir = TempDir::new().unwrap();
359
360 let script = dir.path().join("options-rule.sh");
362 fs::write(
363 &script,
364 r#"#!/bin/sh
365INPUT=$(cat)
366# Check if options.threshold exists in the JSON
367HAS_OPTIONS=$(echo "$INPUT" | grep -c '"threshold"')
368if [ "$HAS_OPTIONS" -gt 0 ]; then
369 echo '{"message": "got options"}'
370else
371 echo '{"message": "no options"}'
372fi
373"#,
374 )
375 .unwrap();
376
377 #[cfg(unix)]
378 {
379 use std::os::unix::fs::PermissionsExt;
380 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
381 }
382
383 let options: toml::Value = toml::from_str("threshold = 5").unwrap();
384 let config = RuleConfig {
385 command: Some(script.to_string_lossy().to_string()),
386 severity: crate::config::RuleSeverity::Warn,
387 files: Vec::new(),
388 ignore: Vec::new(),
389 parsers: Vec::new(),
390 options: Some(options),
391 files_compiled: None,
392 ignore_compiled: None,
393 };
394
395 let enriched = make_enriched(dir.path());
396 let diagnostics =
397 run_one("options-rule", &config, &enriched, dir.path(), dir.path()).unwrap();
398
399 assert_eq!(diagnostics.len(), 1);
400 assert_eq!(diagnostics[0].message, "got options");
401 }
402
403 #[test]
404 fn includes_analyses_in_graph_json() {
405 let dir = TempDir::new().unwrap();
406
407 let script = dir.path().join("analyses-rule.sh");
409 fs::write(
410 &script,
411 r#"#!/bin/sh
412INPUT=$(cat)
413HAS_ANALYSES=$(echo "$INPUT" | grep -c '"analyses"')
414if [ "$HAS_ANALYSES" -gt 0 ]; then
415 echo '{"message": "has analyses"}'
416else
417 echo '{"message": "no analyses"}'
418fi
419"#,
420 )
421 .unwrap();
422
423 #[cfg(unix)]
424 {
425 use std::os::unix::fs::PermissionsExt;
426 fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
427 }
428
429 let config = RuleConfig {
430 command: Some(script.to_string_lossy().to_string()),
431 severity: crate::config::RuleSeverity::Warn,
432 files: Vec::new(),
433 ignore: Vec::new(),
434 parsers: Vec::new(),
435 options: None,
436 files_compiled: None,
437 ignore_compiled: None,
438 };
439
440 let enriched = make_enriched(dir.path());
441 let diagnostics =
442 run_one("analyses-rule", &config, &enriched, dir.path(), dir.path()).unwrap();
443
444 assert_eq!(diagnostics.len(), 1);
445 assert_eq!(diagnostics[0].message, "has analyses");
446 }
447}