browser_automation_cli/
sg_local.rs1use std::fs;
8use std::path::{Path, PathBuf};
9
10use ignore::WalkBuilder;
11use regex::Regex;
12use serde_json::{json, Value};
13
14use crate::error::{CliError, ErrorKind};
15
16#[derive(Debug, Clone)]
18struct Finding {
19 path: String,
20 line: usize,
21 rule: &'static str,
22 snippet: String,
23}
24
25pub fn sg_scan(roots: &[PathBuf], limit: usize) -> Result<Value, CliError> {
27 let roots = if roots.is_empty() {
28 vec![PathBuf::from(".")]
29 } else {
30 roots.to_vec()
31 };
32 let rules = compile_rules();
33 let mut findings = Vec::new();
34 for root in &roots {
35 let mut builder = WalkBuilder::new(root);
36 builder.hidden(false);
37 builder.git_ignore(true);
38 for entry in builder.build() {
39 let entry = match entry {
40 Ok(e) => e,
41 Err(_) => continue,
42 };
43 let path = entry.path();
44 if path
45 .extension()
46 .and_then(|e| e.to_str())
47 .is_none_or(|e| e != "rs")
48 {
49 continue;
50 }
51 let s = path.to_string_lossy();
53 if s.contains("/target/") || s.contains("\\target\\") {
54 continue;
55 }
56 let Ok(text) = fs::read_to_string(path) else {
57 continue;
58 };
59 for (lineno, line) in text.lines().enumerate() {
60 let lineno = lineno + 1;
61 for (rule, re) in &rules {
64 if re.is_match(line) {
65 if *rule == "unwrap_prod"
66 && (s.contains("/tests/")
67 || s.contains("\\tests\\")
68 || path.ends_with("test_utils.rs"))
69 {
70 continue;
71 }
72 findings.push(Finding {
73 path: path.display().to_string(),
74 line: lineno,
75 rule,
76 snippet: line.trim().chars().take(160).collect(),
77 });
78 if limit > 0 && findings.len() >= limit {
79 return Ok(findings_to_json(&findings, false));
80 }
81 }
82 }
83 }
84 }
85 }
86 Ok(findings_to_json(&findings, false))
87}
88
89pub fn sg_rewrite(roots: &[PathBuf], apply: bool) -> Result<Value, CliError> {
91 let roots = if roots.is_empty() {
92 vec![PathBuf::from(".")]
93 } else {
94 roots.to_vec()
95 };
96 let re_env_hint = Regex::new(r#"(?i)export\s+RUST_LOG\s*="#)
99 .map_err(|e| CliError::new(ErrorKind::Software, format!("regex: {e}")))?;
100 let mut changed = Vec::new();
101 let mut planned = 0usize;
102 for root in &roots {
103 let mut builder = WalkBuilder::new(root);
104 builder.git_ignore(true);
105 for entry in builder.build() {
106 let entry = match entry {
107 Ok(e) => e,
108 Err(_) => continue,
109 };
110 let path = entry.path();
111 if path
112 .extension()
113 .and_then(|e| e.to_str())
114 .is_none_or(|e| e != "md" && e != "txt" && e != "rs")
115 {
116 continue;
117 }
118 let s = path.to_string_lossy();
119 if s.contains("/target/") {
120 continue;
121 }
122 let Ok(text) = fs::read_to_string(path) else {
123 continue;
124 };
125 if !re_env_hint.is_match(&text) {
126 continue;
127 }
128 planned += 1;
129 if apply {
130 let new_text = re_env_hint.replace_all(
131 &text,
132 "# (removed product RUST_LOG export โ use XDG log_level) ",
133 );
134 atomic_write(path, new_text.as_ref())?;
135 changed.push(path.display().to_string());
136 } else {
137 changed.push(path.display().to_string());
138 }
139 }
140 }
141 Ok(json!({
142 "ok": true,
143 "apply": apply,
144 "planned": planned,
145 "changed": changed,
146 "note": "Blind AST rewrite is forbidden; only safe RUST_LOG export hints are rewritten",
147 "chrome": false,
148 }))
149}
150
151fn compile_rules() -> Vec<(&'static str, Regex)> {
152 vec![
153 (
154 "telemetry_string",
155 Regex::new(r"(?i)\b(opentelemetry|sentry\.io|telemetry\.|posthog|datadog)\b").unwrap(),
156 ),
157 (
158 "product_env_secret",
159 Regex::new(r#"std::env::var\(\s*"(API_KEY|OPENAI_API_KEY|SECRET|TOKEN|PASSWORD)""#)
160 .unwrap(),
161 ),
162 ("unwrap_prod", Regex::new(r"\.unwrap\(\)").unwrap()),
163 ("dotenv", Regex::new(r"(?i)\bdotenv\b|\.env\b").unwrap()),
164 ]
165}
166
167fn findings_to_json(findings: &[Finding], apply: bool) -> Value {
168 let items: Vec<Value> = findings
169 .iter()
170 .map(|f| {
171 json!({
172 "path": f.path,
173 "line": f.line,
174 "rule": f.rule,
175 "snippet": f.snippet,
176 })
177 })
178 .collect();
179 json!({
180 "ok": true,
181 "count": items.len(),
182 "findings": items,
183 "apply": apply,
184 "chrome": false,
185 })
186}
187
188fn atomic_write(path: &Path, body: &str) -> Result<(), CliError> {
189 let parent = path.parent().unwrap_or_else(|| Path::new("."));
190 let tmp = parent.join(format!(
191 ".browser-automation-cli-sg-{}.tmp",
192 std::process::id()
193 ));
194 fs::write(&tmp, body)
195 .map_err(|e| CliError::new(ErrorKind::Io, format!("write temp {}: {e}", tmp.display())))?;
196 fs::rename(&tmp, path).map_err(|e| {
197 let _ = fs::remove_file(&tmp);
198 CliError::new(
199 ErrorKind::Io,
200 format!("rename {} โ {}: {e}", tmp.display(), path.display()),
201 )
202 })
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use std::io::Write;
209
210 #[test]
211 fn scan_finds_unwrap_in_temp_rs() {
212 let dir = tempfile::tempdir().unwrap();
213 let f = dir.path().join("prod.rs");
214 let mut file = fs::File::create(&f).unwrap();
215 writeln!(file, "fn x() {{ let _ = \"a\".parse::<u32>().unwrap(); }}").unwrap();
216 let v = sg_scan(&[dir.path().to_path_buf()], 50).unwrap();
217 assert!(v.get("count").and_then(|c| c.as_u64()).unwrap_or(0) >= 1);
218 }
219}