1use crate::errors::{FsError, Result};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct Config {
10 #[serde(default)]
12 pub preferences: Preferences,
13 #[serde(default)]
15 pub profiles: HashMap<String, QueryProfile>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Preferences {
21 #[serde(default = "default_format")]
23 pub default_format: String,
24 #[serde(default = "default_true")]
26 pub color: bool,
27 #[serde(default = "default_threads")]
29 pub threads: usize,
30 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct QueryProfile {
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub description: Option<String>,
64 pub command: String,
66 #[serde(default)]
68 pub args: HashMap<String, serde_json::Value>,
69}
70
71impl Config {
72 pub fn load() -> Result<Self> {
74 let config_path = Self::config_file_path()?;
75
76 if !config_path.exists() {
77 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 pub fn save(&self) -> Result<()> {
93 let config_path = Self::config_file_path()?;
94
95 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 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 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 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 let mut config = Config::default();
143
144 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 pub fn get_profile(&self, name: &str) -> Option<&QueryProfile> {
196 self.profiles.get(name)
197 }
198
199 pub fn profile_names(&self) -> Vec<String> {
201 self.profiles.keys().cloned().collect()
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct PxConfig {
208 #[serde(default = "default_scan_dirs")]
210 pub scan_dirs: Vec<PathBuf>,
211
212 #[serde(default = "default_editor")]
214 pub default_editor: String,
215
216 #[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 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 pub fn save(&self) -> Result<()> {
265 let config_path = Self::config_file_path()?;
266
267 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 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 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}