Skip to main content

drft/rules/
script.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::config::{Config, RuleConfig};
5use crate::diagnostic::Diagnostic;
6use crate::graph::Graph;
7
8/// Run all script rules defined in the config against the graph.
9/// Script rules are rules with a `command` field in `[rules]`.
10/// Each script rule receives the graph as JGF JSON on stdin and
11/// emits diagnostics as newline-delimited JSON on stdout.
12///
13/// Expected output format per line:
14/// {"message": "...", "source": "...", "target": "...", "node": "...", "fix": "..."}
15///
16/// All fields except `message` are optional. The `rule` and `severity` fields
17/// are set by drft from the config — the script doesn't need to provide them.
18pub 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                // Surface failures as diagnostics so JSON consumers see them
28                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    // Build the graph JSON to pass on stdin
57    let graph_json = build_graph_json(graph);
58
59    // Parse command string (split on whitespace for simple commands)
60    let parts: Vec<&str> = command.split_whitespace().collect();
61    if parts.is_empty() {
62        anyhow::bail!("empty command");
63    }
64
65    // Resolve command path relative to config directory (where drft.toml lives)
66    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        // Write a simple script that emits one diagnostic
206        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}