syncable_cli/analyzer/hadolint/shell/
shellcheck.rs1use serde::Deserialize;
7use std::process::Command;
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",
55 "2187", "-e",
57 "1090", "-e",
59 "1091", "-", ])
62 .stdin(std::process::Stdio::piped())
63 .stdout(std::process::Stdio::piped())
64 .stderr(std::process::Stdio::piped())
65 .spawn();
66
67 let mut child = match output {
68 Ok(child) => child,
69 Err(_) => {
70 return Vec::new();
72 }
73 };
74
75 if let Some(stdin) = child.stdin.as_mut() {
77 use std::io::Write;
78 let _ = stdin.write_all(script.as_bytes());
79 }
80
81 let output = match child.wait_with_output() {
83 Ok(output) => output,
84 Err(_) => return Vec::new(),
85 };
86
87 let stdout = String::from_utf8_lossy(&output.stdout);
90
91 match serde_json::from_str::<Vec<ShellCheckComment>>(&stdout) {
92 Ok(comments) => comments,
93 Err(_) => Vec::new(),
94 }
95}
96
97pub fn is_shellcheck_available() -> bool {
99 Command::new("shellcheck")
100 .arg("--version")
101 .stdout(std::process::Stdio::null())
102 .stderr(std::process::Stdio::null())
103 .status()
104 .map(|s| s.success())
105 .unwrap_or(false)
106}
107
108pub fn shellcheck_version() -> Option<String> {
110 let output = Command::new("shellcheck").arg("--version").output().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!(
161 has_sc2086 || comments.is_empty(),
162 "Expected SC2086 warning or empty (if shellcheck behaves differently)"
163 );
164 }
165
166 #[test]
167 fn test_shellcheck_comment_rule_code() {
168 let comment = ShellCheckComment {
169 file: "-".to_string(),
170 line: 1,
171 end_line: 1,
172 column: 1,
173 end_column: 10,
174 level: "warning".to_string(),
175 code: 2086,
176 message: "Double quote to prevent globbing".to_string(),
177 };
178
179 assert_eq!(comment.rule_code(), "SC2086");
180 }
181}