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 serde_json::from_str::<Vec<ShellCheckComment>>(&stdout).unwrap_or_default()
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").arg("--version").output().ok()?;
108
109 let stdout = String::from_utf8_lossy(&output.stdout);
110
111 for line in stdout.lines() {
113 if line.starts_with("version:") {
114 return Some(line.trim_start_matches("version:").trim().to_string());
115 }
116 }
117
118 None
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn test_is_shellcheck_available() {
127 let available = is_shellcheck_available();
129 println!("ShellCheck available: {}", available);
130 }
131
132 #[test]
133 fn test_shellcheck_version() {
134 if is_shellcheck_available() {
135 let version = shellcheck_version();
136 println!("ShellCheck version: {:?}", version);
137 assert!(version.is_some());
138 }
139 }
140
141 #[test]
142 fn test_run_shellcheck() {
143 if !is_shellcheck_available() {
144 println!("Skipping test: shellcheck not available");
145 return;
146 }
147
148 let script = r#"#!/bin/bash
150echo $foo
151"#;
152
153 let comments = run_shellcheck(script, "bash");
154
155 let has_sc2086 = comments.iter().any(|c| c.code == 2086);
157 assert!(
158 has_sc2086 || comments.is_empty(),
159 "Expected SC2086 warning or empty (if shellcheck behaves differently)"
160 );
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}