1use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
6use glob::Pattern;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use std::sync::{Arc, RwLock};
10
11use crate::core::config::Config;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default)]
20pub struct WebSettings {
21 #[serde(rename = "streamerMode")]
23 pub streamer_mode: bool,
24
25 #[serde(rename = "hiddenPatterns")]
27 pub hidden_patterns: Vec<String>,
28
29 #[serde(rename = "showDeadBadges")]
31 pub show_dead_badges: bool,
32
33 #[serde(rename = "showCycleIndicators")]
35 pub show_cycle_indicators: bool,
36
37 #[serde(rename = "maxGraphNodes")]
39 pub max_graph_nodes: usize,
40
41 #[serde(rename = "maxListItems")]
43 pub max_list_items: usize,
44
45 #[serde(rename = "compactMode")]
47 pub compact_mode: bool,
48
49 pub theme: String,
51}
52
53impl Default for WebSettings {
54 fn default() -> Self {
55 Self {
56 streamer_mode: false,
57 hidden_patterns: vec![
58 ".env*".to_string(),
59 "*secret*".to_string(),
60 "*credential*".to_string(),
61 "**/config/production.*".to_string(),
62 "**/*.pem".to_string(),
63 "**/*.key".to_string(),
64 "**/secrets/**".to_string(),
65 "**/.aws/**".to_string(),
66 "**/.ssh/**".to_string(),
67 ],
68 show_dead_badges: true,
69 show_cycle_indicators: true,
70 max_graph_nodes: 100,
71 max_list_items: 500,
72 compact_mode: false,
73 theme: "dark".to_string(),
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
80pub struct SettingsUpdate {
81 #[serde(rename = "streamerMode")]
82 pub streamer_mode: Option<bool>,
83
84 #[serde(rename = "hiddenPatterns")]
85 pub hidden_patterns: Option<Vec<String>>,
86
87 #[serde(rename = "showDeadBadges")]
88 pub show_dead_badges: Option<bool>,
89
90 #[serde(rename = "showCycleIndicators")]
91 pub show_cycle_indicators: Option<bool>,
92
93 #[serde(rename = "maxGraphNodes")]
94 pub max_graph_nodes: Option<usize>,
95
96 #[serde(rename = "maxListItems")]
97 pub max_list_items: Option<usize>,
98
99 #[serde(rename = "compactMode")]
100 pub compact_mode: Option<bool>,
101
102 pub theme: Option<String>,
103}
104
105#[derive(Clone)]
107pub struct SettingsState {
108 pub settings: Arc<RwLock<WebSettings>>,
109}
110
111impl SettingsState {
112 pub fn new() -> Self {
113 let settings = load_settings().unwrap_or_default();
114 Self {
115 settings: Arc::new(RwLock::new(settings)),
116 }
117 }
118}
119
120fn settings_path() -> Option<PathBuf> {
126 Config::greppy_home()
127 .ok()
128 .map(|home| home.join("web-settings.json"))
129}
130
131pub fn load_settings() -> Option<WebSettings> {
133 let path = settings_path()?;
134 if !path.exists() {
135 return None;
136 }
137
138 let content = std::fs::read_to_string(&path).ok()?;
139 serde_json::from_str(&content).ok()
140}
141
142pub fn save_settings(settings: &WebSettings) -> std::io::Result<()> {
144 let path = settings_path().ok_or_else(|| {
145 std::io::Error::new(
146 std::io::ErrorKind::NotFound,
147 "Could not determine settings path",
148 )
149 })?;
150
151 if let Some(parent) = path.parent() {
153 std::fs::create_dir_all(parent)?;
154 }
155
156 let content = serde_json::to_string_pretty(settings)?;
157 std::fs::write(&path, content)?;
158
159 Ok(())
160}
161
162pub async fn api_get_settings(State(state): State<SettingsState>) -> Json<WebSettings> {
168 let settings = state.settings.read().unwrap().clone();
169 Json(settings)
170}
171
172pub async fn api_put_settings(
174 State(state): State<SettingsState>,
175 Json(update): Json<SettingsUpdate>,
176) -> impl IntoResponse {
177 let mut settings = state.settings.write().unwrap();
178
179 if let Some(v) = update.streamer_mode {
181 settings.streamer_mode = v;
182 }
183 if let Some(v) = update.hidden_patterns {
184 settings.hidden_patterns = v;
185 }
186 if let Some(v) = update.show_dead_badges {
187 settings.show_dead_badges = v;
188 }
189 if let Some(v) = update.show_cycle_indicators {
190 settings.show_cycle_indicators = v;
191 }
192 if let Some(v) = update.max_graph_nodes {
193 settings.max_graph_nodes = v.max(10).min(500); }
195 if let Some(v) = update.max_list_items {
196 settings.max_list_items = v.max(50).min(2000); }
198 if let Some(v) = update.compact_mode {
199 settings.compact_mode = v;
200 }
201 if let Some(v) = update.theme {
202 settings.theme = v;
203 }
204
205 let settings_clone = settings.clone();
207 drop(settings); match save_settings(&settings_clone) {
210 Ok(_) => (StatusCode::OK, Json(settings_clone)),
211 Err(e) => {
212 eprintln!("Failed to save settings: {}", e);
213 (StatusCode::OK, Json(settings_clone))
215 }
216 }
217}
218
219pub fn should_hide_path(path: &str, settings: &WebSettings) -> bool {
225 if !settings.streamer_mode {
226 return false;
227 }
228
229 for pattern_str in &settings.hidden_patterns {
230 if let Ok(pattern) = Pattern::new(pattern_str) {
232 if pattern.matches(path) {
233 return true;
234 }
235 }
236
237 let simple_pattern = pattern_str
239 .trim_start_matches('*')
240 .trim_end_matches('*')
241 .to_lowercase();
242
243 if !simple_pattern.is_empty() && path.to_lowercase().contains(&simple_pattern) {
244 return true;
245 }
246 }
247
248 false
249}
250
251pub fn redact_path(path: &str, settings: &WebSettings) -> String {
253 if !should_hide_path(path, settings) {
254 return path.to_string();
255 }
256
257 let path_buf = PathBuf::from(path);
259 let extension = path_buf
260 .extension()
261 .map(|e| format!(".{}", e.to_string_lossy()))
262 .unwrap_or_default();
263
264 let parent = path_buf
266 .parent()
267 .map(|p| p.to_string_lossy().to_string())
268 .unwrap_or_default();
269
270 if parent.is_empty() {
272 format!("[HIDDEN]{}", extension)
273 } else {
274 format!("{}/[HIDDEN]{}", parent, extension)
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_should_hide_path() {
284 let mut settings = WebSettings::default();
285 settings.streamer_mode = true;
286
287 assert!(should_hide_path(".env", &settings));
288 assert!(should_hide_path(".env.local", &settings));
289 assert!(should_hide_path("config/secrets.json", &settings));
290 assert!(should_hide_path("credentials.txt", &settings));
291 assert!(!should_hide_path("src/main.rs", &settings));
292 assert!(!should_hide_path("README.md", &settings));
293 }
294
295 #[test]
296 fn test_redact_path() {
297 let mut settings = WebSettings::default();
298 settings.streamer_mode = true;
299
300 assert_eq!(redact_path(".env", &settings), "[HIDDEN]");
301 assert_eq!(
302 redact_path("config/secrets.json", &settings),
303 "config/[HIDDEN].json"
304 );
305 assert_eq!(redact_path("src/main.rs", &settings), "src/main.rs");
306 }
307
308 #[test]
309 fn test_streamer_mode_off() {
310 let settings = WebSettings::default(); assert!(!should_hide_path(".env", &settings));
313 assert_eq!(redact_path(".env", &settings), ".env");
314 }
315}