Skip to main content

drft/rules/
custom.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::analyses::EnrichedGraph;
5use crate::config::{Config, RuleConfig};
6use crate::diagnostic::Diagnostic;
7
8/// Run all custom rules defined in the config against the enriched graph.
9/// Custom rules are rules with a `command` field in `[rules]`.
10/// Each custom rule receives `{ graph, options }` as JSON on stdin —
11/// the enriched graph (nodes, edges, analyses) plus the rule's options —
12/// and emits diagnostics as newline-delimited JSON on stdout.
13///
14/// Expected output format per line:
15/// {"message": "...", "source": "...", "target": "...", "node": "...", "fix": "..."}
16///
17/// All fields except `message` are optional. The `rule` and `severity` fields
18/// are set by drft from the config — the command doesn't need to provide them.
19pub 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                // Surface failures as diagnostics so JSON consumers see them
29                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    // Build the enriched graph + options JSON to pass on stdin
58    let graph_json = build_enriched_json(enriched, rule_config.options.as_ref());
59
60    // Parse command string (split on whitespace for simple commands)
61    let parts: Vec<&str> = command.split_whitespace().collect();
62    if parts.is_empty() {
63        anyhow::bail!("empty command");
64    }
65
66    // Resolve command path relative to config directory (where drft.toml lives)
67    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
137/// Build the JSON envelope sent to custom rules: `{ graph, options }`.
138///
139/// The `graph` object contains the full enriched graph — nodes, edges,
140/// and all analysis results. `options` carries the rule's `[rules.<name>.options]`.
141fn 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        // Write a simple script that emits one diagnostic
250        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        // config_dir is the parent, root is a child subdirectory
317        let config_dir = dir.path();
318        let root = dir.path().join("docs");
319        fs::create_dir_all(&root).unwrap();
320
321        // Script lives relative to config_dir, not root
322        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        // config_dir != root — script should resolve relative to config_dir
350        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        // Script reads stdin, parses the JSON, and echoes back whether options were received
361        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        // Script checks that analyses are present in the graph JSON
408        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}