syncable_cli/analyzer/hadolint/shell/
shellcheck.rs1use std::process::Command;
7use serde::Deserialize;
8
9#[derive(Debug, Clone, Deserialize)]
11pub struct ShellCheckComment {
12 pub file: String,
14 pub line: u32,
16 #[serde(rename = "endLine")]
18 pub end_line: u32,
19 pub column: u32,
21 #[serde(rename = "endColumn")]
23 pub end_column: u32,
24 pub level: String,
26 pub code: u32,
28 pub message: String,
30}
31
32impl ShellCheckComment {
33 pub fn rule_code(&self) -> String {
35 format!("SC{}", self.code)
36 }
37}
38
39pub fn run_shellcheck(script: &str, shell: &str) -> Vec<ShellCheckComment> {
49 let output = Command::new("shellcheck")
51 .args([
52 "--format=json",
53 &format!("--shell={}", shell),
54 "-e", "2187", "-e", "1090", "-e", "1091", "-", ])
59 .stdin(std::process::Stdio::piped())
60 .stdout(std::process::Stdio::piped())
61 .stderr(std::process::Stdio::piped())
62 .spawn();
63
64 let mut child = match output {
65 Ok(child) => child,
66 Err(_) => {
67 return Vec::new();
69 }
70 };
71
72 if let Some(stdin) = child.stdin.as_mut() {
74 use std::io::Write;
75 let _ = stdin.write_all(script.as_bytes());
76 }
77
78 let output = match child.wait_with_output() {
80 Ok(output) => output,
81 Err(_) => return Vec::new(),
82 };
83
84 let stdout = String::from_utf8_lossy(&output.stdout);
87
88 match serde_json::from_str::<Vec<ShellCheckComment>>(&stdout) {
89 Ok(comments) => comments,
90 Err(_) => Vec::new(),
91 }
92}
93
94pub fn is_shellcheck_available() -> bool {
96 Command::new("shellcheck")
97 .arg("--version")
98 .stdout(std::process::Stdio::null())
99 .stderr(std::process::Stdio::null())
100 .status()
101 .map(|s| s.success())
102 .unwrap_or(false)
103}
104
105pub fn shellcheck_version() -> Option<String> {
107 let output = Command::new("shellcheck")
108 .arg("--version")
109 .output()
110 .ok()?;
111
112 let stdout = String::from_utf8_lossy(&output.stdout);
113
114 for line in stdout.lines() {
116 if line.starts_with("version:") {
117 return Some(line.trim_start_matches("version:").trim().to_string());
118 }
119 }
120
121 None
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn test_is_shellcheck_available() {
130 let available = is_shellcheck_available();
132 println!("ShellCheck available: {}", available);
133 }
134
135 #[test]
136 fn test_shellcheck_version() {
137 if is_shellcheck_available() {
138 let version = shellcheck_version();
139 println!("ShellCheck version: {:?}", version);
140 assert!(version.is_some());
141 }
142 }
143
144 #[test]
145 fn test_run_shellcheck() {
146 if !is_shellcheck_available() {
147 println!("Skipping test: shellcheck not available");
148 return;
149 }
150
151 let script = r#"#!/bin/bash
153echo $foo
154"#;
155
156 let comments = run_shellcheck(script, "bash");
157
158 let has_sc2086 = comments.iter().any(|c| c.code == 2086);
160 assert!(has_sc2086 || comments.is_empty(), "Expected SC2086 warning or empty (if shellcheck behaves differently)");
161 }
162
163 #[test]
164 fn test_shellcheck_comment_rule_code() {
165 let comment = ShellCheckComment {
166 file: "-".to_string(),
167 line: 1,
168 end_line: 1,
169 column: 1,
170 end_column: 10,
171 level: "warning".to_string(),
172 code: 2086,
173 message: "Double quote to prevent globbing".to_string(),
174 };
175
176 assert_eq!(comment.rule_code(), "SC2086");
177 }
178}