rust_filesearch/
config.rs

1use crate::errors::{FsError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7/// Main configuration file structure
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct Config {
10    /// User preferences
11    #[serde(default)]
12    pub preferences: Preferences,
13    /// Saved query profiles
14    #[serde(default)]
15    pub profiles: HashMap<String, QueryProfile>,
16}
17
18/// User preferences
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Preferences {
21    /// Default output format
22    #[serde(default = "default_format")]
23    pub default_format: String,
24    /// Enable colored output by default
25    #[serde(default = "default_true")]
26    pub color: bool,
27    /// Number of threads for parallel operations
28    #[serde(default = "default_threads")]
29    pub threads: usize,
30    /// Respect gitignore by default
31    #[serde(default = "default_true")]
32    pub respect_gitignore: bool,
33}
34
35fn default_format() -> String {
36    "pretty".to_string()
37}
38
39fn default_true() -> bool {
40    true
41}
42
43fn default_threads() -> usize {
44    4
45}
46
47impl Default for Preferences {
48    fn default() -> Self {
49        Self {
50            default_format: default_format(),
51            color: true,
52            threads: 4,
53            respect_gitignore: true,
54        }
55    }
56}
57
58/// Saved query profile
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct QueryProfile {
61    /// Profile description
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub description: Option<String>,
64    /// Command to run (list, find, size, etc.)
65    pub command: String,
66    /// Command arguments as key-value pairs
67    #[serde(default)]
68    pub args: HashMap<String, serde_json::Value>,
69}
70
71impl Config {
72    /// Load config from the default location
73    pub fn load() -> Result<Self> {
74        let config_path = Self::config_file_path()?;
75
76        if !config_path.exists() {
77            // Return default config if file doesn't exist
78            return Ok(Self::default());
79        }
80
81        let content = fs::read_to_string(&config_path).map_err(|e| FsError::PathAccess {
82            path: config_path.clone(),
83            source: e,
84        })?;
85
86        toml::from_str(&content).map_err(|e| FsError::InvalidFormat {
87            format: format!("Failed to parse config file: {}", e),
88        })
89    }
90
91    /// Save config to the default location
92    pub fn save(&self) -> Result<()> {
93        let config_path = Self::config_file_path()?;
94
95        // Create config directory if it doesn't exist
96        if let Some(parent) = config_path.parent() {
97            fs::create_dir_all(parent).map_err(|e| FsError::PathAccess {
98                path: parent.to_path_buf(),
99                source: e,
100            })?;
101        }
102
103        let content = toml::to_string_pretty(self).map_err(|e| FsError::InvalidFormat {
104            format: format!("Failed to serialize config: {}", e),
105        })?;
106
107        fs::write(&config_path, content).map_err(|e| FsError::PathAccess {
108            path: config_path,
109            source: e,
110        })
111    }
112
113    /// Get the default config file path
114    pub fn config_file_path() -> Result<PathBuf> {
115        let config_dir = dirs::config_dir().ok_or_else(|| FsError::InvalidFormat {
116            format: "Could not determine config directory".to_string(),
117        })?;
118
119        Ok(config_dir.join("fexplorer").join("config.toml"))
120    }
121
122    /// Get the config directory path
123    pub fn config_dir() -> Result<PathBuf> {
124        let config_dir = dirs::config_dir().ok_or_else(|| FsError::InvalidFormat {
125            format: "Could not determine config directory".to_string(),
126        })?;
127
128        Ok(config_dir.join("fexplorer"))
129    }
130
131    /// Initialize a default config file with examples
132    pub fn init() -> Result<()> {
133        let config_path = Self::config_file_path()?;
134
135        if config_path.exists() {
136            return Err(FsError::InvalidFormat {
137                format: format!("Config file already exists at {}", config_path.display()),
138            });
139        }
140
141        // Create example config with sample profiles
142        let mut config = Config::default();
143
144        // Add example profiles
145        config.profiles.insert(
146            "cleanup".to_string(),
147            QueryProfile {
148                description: Some("Find old log and temp files for cleanup".to_string()),
149                command: "find".to_string(),
150                args: {
151                    let mut args = HashMap::new();
152                    args.insert("ext".to_string(), serde_json::json!(["log", "tmp"]));
153                    args.insert("before".to_string(), serde_json::json!("30 days ago"));
154                    args.insert("min_size".to_string(), serde_json::json!("1MB"));
155                    args
156                },
157            },
158        );
159
160        config.profiles.insert(
161            "recent-code".to_string(),
162            QueryProfile {
163                description: Some("Find recently modified source code files".to_string()),
164                command: "find".to_string(),
165                args: {
166                    let mut args = HashMap::new();
167                    args.insert(
168                        "ext".to_string(),
169                        serde_json::json!(["rs", "go", "ts", "py"]),
170                    );
171                    args.insert("after".to_string(), serde_json::json!("7 days ago"));
172                    args
173                },
174            },
175        );
176
177        config.profiles.insert(
178            "large-files".to_string(),
179            QueryProfile {
180                description: Some("Find files larger than 100MB".to_string()),
181                command: "find".to_string(),
182                args: {
183                    let mut args = HashMap::new();
184                    args.insert("min_size".to_string(), serde_json::json!("100MB"));
185                    args.insert("kind".to_string(), serde_json::json!(["file"]));
186                    args
187                },
188            },
189        );
190
191        config.save()
192    }
193
194    /// Get a profile by name
195    pub fn get_profile(&self, name: &str) -> Option<&QueryProfile> {
196        self.profiles.get(name)
197    }
198
199    /// List all profile names
200    pub fn profile_names(&self) -> Vec<String> {
201        self.profiles.keys().cloned().collect()
202    }
203}
204
205/// Configuration for px (project switcher)
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct PxConfig {
208    /// Directories to scan for projects
209    #[serde(default = "default_scan_dirs")]
210    pub scan_dirs: Vec<PathBuf>,
211
212    /// Default editor command
213    #[serde(default = "default_editor")]
214    pub default_editor: String,
215
216    /// Optional Obsidian vault path for note integration
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub obsidian_vault: Option<PathBuf>,
219}
220
221fn default_scan_dirs() -> Vec<PathBuf> {
222    let home = dirs::home_dir().unwrap_or_default();
223    vec![
224        home.join("Developer"),
225        home.join("projects"),
226        home.join("code"),
227    ]
228}
229
230fn default_editor() -> String {
231    "code".to_string()
232}
233
234impl Default for PxConfig {
235    fn default() -> Self {
236        Self {
237            scan_dirs: default_scan_dirs(),
238            default_editor: default_editor(),
239            obsidian_vault: None,
240        }
241    }
242}
243
244impl PxConfig {
245    /// Load px config from ~/.config/px/config.toml
246    pub fn load() -> Result<Self> {
247        let config_path = Self::config_file_path()?;
248
249        if !config_path.exists() {
250            return Ok(Self::default());
251        }
252
253        let content = fs::read_to_string(&config_path).map_err(|e| FsError::PathAccess {
254            path: config_path.clone(),
255            source: e,
256        })?;
257
258        toml::from_str(&content).map_err(|e| FsError::InvalidFormat {
259            format: format!("Failed to parse px config: {}", e),
260        })
261    }
262
263    /// Save px config
264    pub fn save(&self) -> Result<()> {
265        let config_path = Self::config_file_path()?;
266
267        // Create config directory if it doesn't exist
268        if let Some(parent) = config_path.parent() {
269            fs::create_dir_all(parent).map_err(|e| FsError::PathAccess {
270                path: parent.to_path_buf(),
271                source: e,
272            })?;
273        }
274
275        let content = toml::to_string_pretty(self).map_err(|e| FsError::InvalidFormat {
276            format: format!("Failed to serialize px config: {}", e),
277        })?;
278
279        fs::write(&config_path, content).map_err(|e| FsError::PathAccess {
280            path: config_path,
281            source: e,
282        })
283    }
284
285    /// Get config file path (~/.config/px/config.toml)
286    pub fn config_file_path() -> Result<PathBuf> {
287        let config_dir = dirs::config_dir().ok_or_else(|| FsError::InvalidFormat {
288            format: "Could not determine config directory".to_string(),
289        })?;
290
291        Ok(config_dir.join("px").join("config.toml"))
292    }
293
294    /// Initialize default px config with helpful comments
295    pub fn init() -> Result<()> {
296        let config_path = Self::config_file_path()?;
297
298        if config_path.exists() {
299            println!("Config file already exists at: {}", config_path.display());
300            println!("Edit manually or delete to regenerate");
301            return Ok(());
302        }
303
304        let config = Self::default();
305        config.save()?;
306
307        println!("✓ Created px config at: {}", config_path.display());
308        println!();
309        println!("Edit this file to customize:");
310        println!("  - scan_dirs: directories to search for projects");
311        println!("  - default_editor: editor command (code, cursor, vim, etc.)");
312        println!("  - obsidian_vault: optional Obsidian vault path");
313
314        Ok(())
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_default_config() {
324        let config = Config::default();
325        assert_eq!(config.preferences.default_format, "pretty");
326        assert!(config.preferences.color);
327        assert_eq!(config.preferences.threads, 4);
328        assert!(config.preferences.respect_gitignore);
329    }
330
331    #[test]
332    fn test_config_serialization() {
333        let mut config = Config::default();
334        config.profiles.insert(
335            "test".to_string(),
336            QueryProfile {
337                description: Some("Test profile".to_string()),
338                command: "list".to_string(),
339                args: HashMap::new(),
340            },
341        );
342
343        let toml_str = toml::to_string(&config).unwrap();
344        assert!(toml_str.contains("[preferences]"));
345        assert!(toml_str.contains("profiles.test"));
346    }
347
348    #[test]
349    fn test_config_deserialization() {
350        let toml_str = r#"
351            [preferences]
352            default_format = "json"
353            color = false
354            threads = 8
355
356            [profiles.example]
357            description = "Example profile"
358            command = "find"
359            args = { ext = ["rs"] }
360        "#;
361
362        let config: Config = toml::from_str(toml_str).unwrap();
363        assert_eq!(config.preferences.default_format, "json");
364        assert!(!config.preferences.color);
365        assert_eq!(config.preferences.threads, 8);
366        assert!(config.profiles.contains_key("example"));
367    }
368}