1use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::OnceLock;
20
21use ignore::WalkBuilder;
22use rayon::prelude::*;
23use regex::Regex;
24use serde_json::{json, Value};
25
26use crate::error::{CliError, ErrorKind};
27
28#[derive(Debug, Clone)]
30struct Finding {
31 path: String,
32 line: usize,
33 rule: &'static str,
34 snippet: String,
35}
36
37pub fn sg_scan(roots: &[PathBuf], limit: usize) -> Result<Value, CliError> {
39 crate::concurrency::install_rayon_pool_once();
40 let roots = if roots.is_empty() {
41 vec![PathBuf::from(".")]
42 } else {
43 roots.to_vec()
44 };
45 let rules = compiled_rules();
46
47 let walk_threads = crate::concurrency::walk_threads();
50 let collect_root = |root: &PathBuf| -> Vec<PathBuf> {
51 let mut local = Vec::new();
52 let mut builder = WalkBuilder::new(root);
53 builder.hidden(false);
54 builder.git_ignore(true);
55 builder.threads(walk_threads);
56 for entry in builder.build() {
57 let entry = match entry {
58 Ok(e) => e,
59 Err(_) => continue,
60 };
61 let path = entry.path();
62 if path
63 .extension()
64 .and_then(|e| e.to_str())
65 .is_none_or(|e| e != "rs")
66 {
67 continue;
68 }
69 let s = path.to_string_lossy();
70 if s.contains("/target/") || s.contains("\\target\\") {
71 continue;
72 }
73 local.push(path.to_path_buf());
74 }
75 local
76 };
77 let paths: Vec<PathBuf> = if roots.len() <= 1 {
78 roots.iter().flat_map(collect_root).collect()
79 } else {
80 roots.par_iter().flat_map(collect_root).collect()
81 };
82
83 let mut findings: Vec<Finding> = paths
85 .par_iter()
86 .flat_map_iter(|path| scan_file(path, rules))
87 .collect();
88
89 crate::concurrency::sort_by_cpu(&mut findings, |a, b| {
91 (&a.path, a.line, a.rule).cmp(&(&b.path, b.line, b.rule))
92 });
93 if limit > 0 && findings.len() > limit {
94 findings.truncate(limit);
95 }
96
97 Ok(findings_to_json(&findings, false))
98}
99
100fn scan_file(path: &Path, rules: &[(&'static str, Regex)]) -> Vec<Finding> {
101 let Ok(text) = fs::read_to_string(path) else {
102 return Vec::new();
103 };
104 let s = path.to_string_lossy();
105 let mut out = Vec::new();
106 for (lineno, line) in text.lines().enumerate() {
107 let lineno = lineno + 1;
108 for (rule, re) in rules {
109 if re.is_match(line) {
110 if *rule == "unwrap_prod"
111 && (s.contains("/tests/")
112 || s.contains("\\tests\\")
113 || path.ends_with("test_utils.rs"))
114 {
115 continue;
116 }
117 out.push(Finding {
118 path: path.display().to_string(),
119 line: lineno,
120 rule,
121 snippet: line.trim().chars().take(160).collect(),
122 });
123 }
124 }
125 }
126 out
127}
128
129pub fn sg_rewrite(roots: &[PathBuf], apply: bool) -> Result<Value, CliError> {
138 crate::concurrency::install_rayon_pool_once();
139 let roots = if roots.is_empty() {
140 vec![PathBuf::from(".")]
141 } else {
142 roots.to_vec()
143 };
144 let re_env_hint = re_rust_log_export();
147 let walk_threads = crate::concurrency::walk_threads();
148 let collect_root = |root: &PathBuf| -> Vec<PathBuf> {
149 let mut local = Vec::new();
150 let mut builder = WalkBuilder::new(root);
151 builder.git_ignore(true);
152 builder.threads(walk_threads);
153 for entry in builder.build() {
154 let entry = match entry {
155 Ok(e) => e,
156 Err(_) => continue,
157 };
158 let path = entry.path();
159 if path
160 .extension()
161 .and_then(|e| e.to_str())
162 .is_none_or(|e| e != "md" && e != "txt" && e != "rs")
163 {
164 continue;
165 }
166 let s = path.to_string_lossy();
167 if s.contains("/target/") {
168 continue;
169 }
170 local.push(path.to_path_buf());
171 }
172 local
173 };
174 let mut paths: Vec<PathBuf> = if roots.len() <= 1 {
175 roots.iter().flat_map(collect_root).collect()
176 } else {
177 roots.par_iter().flat_map(collect_root).collect()
178 };
179
180 let replacement = "# (removed product RUST_LOG export — use XDG log_level) ";
181 let mut changed = Vec::new();
182 let planned;
183
184 if apply {
185 crate::concurrency::sort_cpu(&mut paths);
188 let mut n = 0usize;
189 for path in &paths {
190 let Ok(text) = fs::read_to_string(path) else {
191 continue;
192 };
193 if !re_env_hint.is_match(&text) {
194 continue;
195 }
196 n += 1;
197 let new_text = re_env_hint.replace_all(&text, replacement);
198 atomic_write(path, new_text.as_ref())?;
199 changed.push(path.display().to_string());
200 }
201 planned = n;
202 } else {
203 let mut hits: Vec<String> = paths
205 .par_iter()
206 .filter_map(|path| {
207 let text = fs::read_to_string(path).ok()?;
208 if re_env_hint.is_match(&text) {
209 Some(path.display().to_string())
210 } else {
211 None
212 }
213 })
214 .collect();
215 crate::concurrency::sort_cpu(&mut hits);
216 planned = hits.len();
217 changed = hits;
218 }
219
220 Ok(json!({
221 "ok": true,
222 "apply": apply,
223 "planned": planned,
224 "changed": changed,
225 "note": "Blind AST rewrite is forbidden; only safe RUST_LOG export hints are rewritten",
226 "chrome": false,
227 "parallel_walk": true,
228 "dry_run_rayon": !apply,
229 "apply_sequential": apply,
230 "concurrency": crate::concurrency::effective_limit(),
231 }))
232}
233
234fn re_rust_log_export() -> &'static Regex {
235 static RE: OnceLock<Regex> = OnceLock::new();
236 RE.get_or_init(|| {
237 Regex::new(r#"(?i)export\s+RUST_LOG\s*="#).expect("RUST_LOG export regex")
238 })
239}
240
241fn compiled_rules() -> &'static [(&'static str, Regex)] {
242 static RULES: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
243 RULES
244 .get_or_init(|| {
245 vec![
246 (
247 "telemetry_string",
248 Regex::new(
249 r"(?i)\b(opentelemetry|sentry\.io|telemetry\.|posthog|datadog)\b",
250 )
251 .expect("telemetry regex"),
252 ),
253 (
254 "product_env_secret",
255 Regex::new(
256 r#"std::env::var\(\s*"(API_KEY|OPENAI_API_KEY|SECRET|TOKEN|PASSWORD)""#,
257 )
258 .expect("env secret regex"),
259 ),
260 (
261 "unwrap_prod",
262 Regex::new(r"\.unwrap\(\)").expect("unwrap regex"),
263 ),
264 (
265 "dotenv",
266 Regex::new(r"(?i)\bdotenv\b|\.env\b").expect("dotenv regex"),
267 ),
268 ]
269 })
270 .as_slice()
271}
272
273fn findings_to_json(findings: &[Finding], apply: bool) -> Value {
274 let items: Vec<Value> = findings
275 .iter()
276 .map(|f| {
277 json!({
278 "path": f.path,
279 "line": f.line,
280 "rule": f.rule,
281 "snippet": f.snippet,
282 })
283 })
284 .collect();
285 json!({
286 "ok": true,
287 "count": items.len(),
288 "findings": items,
289 "apply": apply,
290 "engine": "sg-local",
291 "parallel": true,
292 "concurrency": crate::concurrency::effective_limit(),
293 "chrome": false,
294 })
295}
296
297fn atomic_write(path: &Path, body: &str) -> Result<(), CliError> {
298 let parent = path.parent().unwrap_or_else(|| Path::new("."));
299 let tmp = parent.join(format!(
300 ".browser-automation-cli-sg-{}.tmp",
301 std::process::id()
302 ));
303 fs::write(&tmp, body).map_err(|e| {
304 CliError::new(
305 ErrorKind::Io,
306 format!("write temp {}: {e}", tmp.display()),
307 )
308 })?;
309 fs::rename(&tmp, path).map_err(|e| {
310 let _ = fs::remove_file(&tmp);
311 CliError::new(
312 ErrorKind::Io,
313 format!("rename {} → {}: {e}", tmp.display(), path.display()),
314 )
315 })
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::io::Write;
322
323 #[test]
324 fn scan_finds_unwrap_in_temp_rs() {
325 let dir = tempfile::tempdir().unwrap();
326 let f = dir.path().join("prod.rs");
327 let mut file = fs::File::create(&f).unwrap();
328 writeln!(
329 file,
330 "fn x() {{ let _ = \"a\".parse::<u32>().unwrap(); }}"
331 )
332 .unwrap();
333 let v = sg_scan(&[dir.path().to_path_buf()], 50).unwrap();
334 assert!(v.get("count").and_then(|c| c.as_u64()).unwrap_or(0) >= 1);
335 }
336}