greppy/web/
settings.rs

1//! Settings API endpoints
2//!
3//! Provides endpoints for managing web UI settings including streamer mode.
4
5use 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// =============================================================================
14// TYPES
15// =============================================================================
16
17/// Web UI settings
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(default)]
20pub struct WebSettings {
21    /// Streamer mode - hides sensitive files
22    #[serde(rename = "streamerMode")]
23    pub streamer_mode: bool,
24
25    /// Glob patterns for files to hide in streamer mode
26    #[serde(rename = "hiddenPatterns")]
27    pub hidden_patterns: Vec<String>,
28
29    /// Show dead code badges in UI
30    #[serde(rename = "showDeadBadges")]
31    pub show_dead_badges: bool,
32
33    /// Show cycle indicators in UI
34    #[serde(rename = "showCycleIndicators")]
35    pub show_cycle_indicators: bool,
36
37    /// Maximum nodes to render in graph view
38    #[serde(rename = "maxGraphNodes")]
39    pub max_graph_nodes: usize,
40
41    /// Maximum items to show in list view
42    #[serde(rename = "maxListItems")]
43    pub max_list_items: usize,
44
45    /// Compact mode - reduces spacing
46    #[serde(rename = "compactMode")]
47    pub compact_mode: bool,
48
49    /// Theme (reserved for future use)
50    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/// Partial settings update (for PATCH-like behavior)
79#[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/// Shared state for settings
106#[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
120// =============================================================================
121// PERSISTENCE
122// =============================================================================
123
124/// Get the settings file path
125fn settings_path() -> Option<PathBuf> {
126    Config::greppy_home()
127        .ok()
128        .map(|home| home.join("web-settings.json"))
129}
130
131/// Load settings from disk
132pub 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
142/// Save settings to disk
143pub 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    // Ensure directory exists
152    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
162// =============================================================================
163// ROUTE HANDLERS
164// =============================================================================
165
166/// GET /api/settings - Get current settings
167pub async fn api_get_settings(State(state): State<SettingsState>) -> Json<WebSettings> {
168    let settings = state.settings.read().unwrap().clone();
169    Json(settings)
170}
171
172/// PUT /api/settings - Update settings (full replace or partial update)
173pub 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    // Apply partial updates
180    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); // Clamp to reasonable range
194    }
195    if let Some(v) = update.max_list_items {
196        settings.max_list_items = v.max(50).min(2000); // Clamp to reasonable range
197    }
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    // Save to disk
206    let settings_clone = settings.clone();
207    drop(settings); // Release lock before IO
208
209    match save_settings(&settings_clone) {
210        Ok(_) => (StatusCode::OK, Json(settings_clone)),
211        Err(e) => {
212            eprintln!("Failed to save settings: {}", e);
213            // Still return the settings even if save failed
214            (StatusCode::OK, Json(settings_clone))
215        }
216    }
217}
218
219// =============================================================================
220// STREAMER MODE HELPERS
221// =============================================================================
222
223/// Check if a path should be hidden based on streamer mode patterns
224pub 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        // Try to match as glob pattern
231        if let Ok(pattern) = Pattern::new(pattern_str) {
232            if pattern.matches(path) {
233                return true;
234            }
235        }
236
237        // Also try simple contains match for patterns like "*secret*"
238        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
251/// Redact a path for display in streamer mode
252pub fn redact_path(path: &str, settings: &WebSettings) -> String {
253    if !should_hide_path(path, settings) {
254        return path.to_string();
255    }
256
257    // Extract file name and extension
258    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    // Get parent path
265    let parent = path_buf
266        .parent()
267        .map(|p| p.to_string_lossy().to_string())
268        .unwrap_or_default();
269
270    // Return redacted version
271    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(); // streamer_mode = false
311
312        assert!(!should_hide_path(".env", &settings));
313        assert_eq!(redact_path(".env", &settings), ".env");
314    }
315}