1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6
7pub const APP_DISPLAY_NAME: &str = "Nex";
8pub const LEGACY_APP_DISPLAY_NAME: &str = "SwiftFind";
9#[cfg(target_os = "windows")]
10const APP_DIR_NAME_WINDOWS: &str = "Nex";
11#[cfg(target_os = "windows")]
12const LEGACY_APP_DIR_NAME_WINDOWS: &str = "SwiftFind";
13const APP_DIR_NAME_UNIX: &str = "nex";
14const LEGACY_APP_DIR_NAME_UNIX: &str = "swiftfind";
15const CONFIG_FILE_NAME: &str = "config.toml";
16const LEGACY_CONFIG_FILE_NAME: &str = "config.json";
17
18pub const CURRENT_CONFIG_VERSION: u32 = 11;
19const LEGACY_IDLE_CACHE_TRIM_MS_V1: u32 = 1200;
20const LEGACY_ACTIVE_MEMORY_TARGET_MB_V1: u16 = 80;
21const TEMPLATE_REQUIRED_KEYS: &[&str] = &[
22 "hotkey",
23 "launch_at_startup",
24 "max_results",
25 "discovery_roots",
26 "discovery_exclude_roots",
27 "windows_search_enabled",
28 "windows_search_fallback_filesystem",
29 "show_files",
30 "show_folders",
31 "search_mode_default",
32 "search_dsl_enabled",
33 "search_query_results_with_delay",
34 "search_delay_time_ms",
35 "uninstall_actions_enabled",
36 "web_search_provider",
37 "web_search_custom_template",
38 "clipboard_enabled",
39 "clipboard_retention_minutes",
40 "clipboard_exclude_sensitive_patterns",
41 "plugins_enabled",
42 "plugins_safe_mode",
43 "game_mode_enabled",
44 "plugin_paths",
45 "idle_cache_trim_ms",
46 "active_memory_target_mb",
47 "index_max_items_total",
48 "index_max_items_per_root",
49 "index_max_items_per_query_seed",
50];
51
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
53#[serde(rename_all = "snake_case")]
54pub enum SearchMode {
55 #[default]
56 All,
57 Apps,
58 Files,
59 Actions,
60 Clipboard,
61}
62
63impl SearchMode {
64 pub fn parse(value: &str) -> Option<Self> {
65 let normalized = value.trim().to_ascii_lowercase();
66 match normalized.as_str() {
67 "all" => Some(Self::All),
68 "apps" | "app" => Some(Self::Apps),
69 "files" | "file" => Some(Self::Files),
70 "actions" | "action" => Some(Self::Actions),
71 "clipboard" | "clip" => Some(Self::Clipboard),
72 _ => None,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
78#[serde(rename_all = "snake_case")]
79pub enum WebSearchProvider {
80 Duckduckgo,
81 #[default]
82 Google,
83 Bing,
84 Brave,
85 Startpage,
86 Ecosia,
87 Yahoo,
88 Custom,
89}
90
91impl WebSearchProvider {
92 pub fn label(self) -> &'static str {
93 match self {
94 Self::Duckduckgo => "DuckDuckGo",
95 Self::Google => "Google",
96 Self::Bing => "Bing",
97 Self::Brave => "Brave",
98 Self::Startpage => "Startpage",
99 Self::Ecosia => "Ecosia",
100 Self::Yahoo => "Yahoo",
101 Self::Custom => "Custom",
102 }
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(default)]
108pub struct Config {
109 pub version: u32,
110 pub max_results: u16,
111 pub index_db_path: PathBuf,
112 pub config_path: PathBuf,
113 pub discovery_roots: Vec<PathBuf>,
114 pub discovery_exclude_roots: Vec<PathBuf>,
115 pub windows_search_enabled: bool,
116 pub windows_search_fallback_filesystem: bool,
117 pub show_files: bool,
118 pub show_folders: bool,
119 pub hotkey: String,
120 pub launch_at_startup: bool,
121 pub hotkey_help: String,
122 pub hotkey_recommended: Vec<String>,
123 pub search_mode_default: SearchMode,
124 pub search_dsl_enabled: bool,
125 pub search_query_results_with_delay: bool,
126 pub search_delay_time_ms: u16,
127 pub uninstall_actions_enabled: bool,
128 pub web_search_provider: WebSearchProvider,
129 pub web_search_custom_template: String,
130 pub clipboard_enabled: bool,
131 pub clipboard_retention_minutes: u32,
132 pub clipboard_exclude_sensitive_patterns: Vec<String>,
133 pub plugins_enabled: bool,
134 pub plugin_paths: Vec<PathBuf>,
135 pub plugins_safe_mode: bool,
136 pub game_mode_enabled: bool,
137 pub idle_cache_trim_ms: u32,
138 pub active_memory_target_mb: u16,
139 pub index_max_items_total: u32,
140 pub index_max_items_per_root: u32,
141 pub index_max_items_per_query_seed: u32,
142}
143
144impl Default for Config {
145 fn default() -> Self {
146 let app_dir = stable_app_data_dir();
147 let config_path = app_dir.join(CONFIG_FILE_NAME);
148 Self {
149 version: CURRENT_CONFIG_VERSION,
150 max_results: 20,
151 index_db_path: app_dir.join("index.sqlite3"),
152 config_path,
153 discovery_roots: default_discovery_roots(),
154 discovery_exclude_roots: default_discovery_exclude_roots(),
155 windows_search_enabled: true,
156 windows_search_fallback_filesystem: true,
157 show_files: false,
158 show_folders: false,
159 hotkey: "Ctrl+Space".to_string(),
160 launch_at_startup: false,
161 hotkey_help: format!(
162 "Set `hotkey` as Modifier+Key (example: Ctrl+Space), then restart {APP_DISPLAY_NAME}."
163 ),
164 hotkey_recommended: vec![
165 "Ctrl+Space".to_string(),
166 "Ctrl+Shift+Space".to_string(),
167 "Ctrl+Alt+Space".to_string(),
168 "Alt+Shift+Space".to_string(),
169 "Ctrl+Shift+P".to_string(),
170 "Ctrl+Alt+P".to_string(),
171 ],
172 search_mode_default: SearchMode::All,
173 search_dsl_enabled: true,
174 search_query_results_with_delay: true,
175 search_delay_time_ms: 90,
176 uninstall_actions_enabled: true,
177 web_search_provider: WebSearchProvider::Google,
178 web_search_custom_template: String::new(),
179 clipboard_enabled: true,
180 clipboard_retention_minutes: 8 * 60,
181 clipboard_exclude_sensitive_patterns: vec![
182 "password".to_string(),
183 "passcode".to_string(),
184 "otp".to_string(),
185 "token".to_string(),
186 "secret".to_string(),
187 "apikey".to_string(),
188 "api_key".to_string(),
189 ],
190 plugins_enabled: true,
191 plugin_paths: vec![app_dir.join("plugins")],
192 plugins_safe_mode: true,
193 game_mode_enabled: false,
194 idle_cache_trim_ms: 900,
195 active_memory_target_mb: 72,
196 index_max_items_total: 120_000,
197 index_max_items_per_root: 40_000,
198 index_max_items_per_query_seed: 5_000,
199 }
200 }
201}
202
203#[derive(Debug)]
204pub enum ConfigError {
205 Io(std::io::Error),
206 Parse(String),
207 Validation(String),
208}
209
210impl Display for ConfigError {
211 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
212 match self {
213 Self::Io(error) => write!(f, "io error: {error}"),
214 Self::Parse(error) => write!(f, "parse error: {error}"),
215 Self::Validation(error) => write!(f, "validation error: {error}"),
216 }
217 }
218}
219
220impl std::error::Error for ConfigError {}
221
222impl From<std::io::Error> for ConfigError {
223 fn from(value: std::io::Error) -> Self {
224 Self::Io(value)
225 }
226}
227
228impl From<serde_json::Error> for ConfigError {
229 fn from(value: serde_json::Error) -> Self {
230 Self::Parse(value.to_string())
231 }
232}
233
234pub fn stable_app_data_dir() -> PathBuf {
235 #[cfg(target_os = "windows")]
236 {
237 if let Some(preferred) = windows_app_data_dir(APP_DIR_NAME_WINDOWS) {
238 return migrate_legacy_app_data_dir(
239 preferred,
240 windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS),
241 );
242 }
243 }
244
245 #[cfg(not(target_os = "windows"))]
246 {
247 if let Some(preferred) = unix_app_data_dir(APP_DIR_NAME_UNIX) {
248 return migrate_legacy_app_data_dir(
249 preferred,
250 unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX),
251 );
252 }
253 }
254
255 std::env::temp_dir().join(APP_DIR_NAME_UNIX)
256}
257
258pub fn stable_config_path() -> PathBuf {
259 stable_app_data_dir().join(CONFIG_FILE_NAME)
260}
261
262fn stable_legacy_config_paths() -> Vec<PathBuf> {
263 let current_dir = stable_app_data_dir();
264 let mut paths = vec![current_dir.join(LEGACY_CONFIG_FILE_NAME)];
265
266 #[cfg(target_os = "windows")]
267 {
268 if let Some(legacy_dir) = windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS) {
269 if legacy_dir != current_dir {
270 paths.push(legacy_dir.join(LEGACY_CONFIG_FILE_NAME));
271 }
272 }
273 }
274
275 #[cfg(not(target_os = "windows"))]
276 {
277 if let Some(legacy_dir) = unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX) {
278 if legacy_dir != current_dir {
279 paths.push(legacy_dir.join(LEGACY_CONFIG_FILE_NAME));
280 }
281 }
282 }
283
284 paths.sort();
285 paths.dedup();
286 paths
287}
288
289fn is_toml_path(path: &Path) -> bool {
290 path.extension()
291 .and_then(|ext| ext.to_str())
292 .map(|ext| ext.eq_ignore_ascii_case("toml"))
293 .unwrap_or(false)
294}
295
296pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> {
297 let resolved_path = path
298 .map(Path::to_path_buf)
299 .unwrap_or_else(stable_config_path);
300
301 if !resolved_path.exists() {
302 if path.is_none() {
303 if let Some(legacy_path) = stable_legacy_config_paths()
304 .into_iter()
305 .find(|candidate| candidate.exists())
306 {
307 let raw = std::fs::read_to_string(&legacy_path)?;
308 let mut cfg: Config = parse_text(&raw)?;
309 let source_version = cfg.version;
310 cfg.config_path = resolved_path.clone();
311
312 if cfg.index_db_path.as_os_str().is_empty() {
313 cfg.index_db_path = resolved_path
314 .parent()
315 .unwrap_or_else(|| Path::new("."))
316 .join("index.sqlite3");
317 }
318
319 let mut should_persist_migration = apply_migrations(&mut cfg, &raw);
320 should_persist_migration |= rewrite_managed_paths_to_current_app_dir(&mut cfg);
321 validate(&cfg).map_err(ConfigError::Validation)?;
322 if should_persist_migration {
323 persist_migrated_config(
324 &cfg,
325 &resolved_path,
326 &raw,
327 source_version,
328 &legacy_path,
329 )?;
330 }
331 return Ok(cfg);
332 }
333 }
334
335 let cfg = default_for_path(&resolved_path);
336 validate(&cfg).map_err(ConfigError::Validation)?;
337 return Ok(cfg);
338 }
339
340 let raw = std::fs::read_to_string(&resolved_path)?;
341 let mut cfg: Config = parse_text(&raw)?;
342 let source_version = cfg.version;
343 cfg.config_path = resolved_path.clone();
344
345 if cfg.index_db_path.as_os_str().is_empty() {
346 cfg.index_db_path = resolved_path
347 .parent()
348 .unwrap_or_else(|| Path::new("."))
349 .join("index.sqlite3");
350 }
351
352 let mut should_persist_migration = apply_migrations(&mut cfg, &raw);
353 should_persist_migration |= rewrite_managed_paths_to_current_app_dir(&mut cfg);
354 validate(&cfg).map_err(ConfigError::Validation)?;
355 if should_persist_migration {
356 persist_migrated_config(&cfg, &resolved_path, &raw, source_version, &resolved_path)?;
357 }
358 Ok(cfg)
359}
360
361pub fn save(cfg: &Config) -> Result<(), ConfigError> {
362 save_to_path(cfg, &cfg.config_path)
363}
364
365pub fn save_to_path(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
366 validate(cfg).map_err(ConfigError::Validation)?;
367
368 if let Some(parent) = path.parent() {
369 std::fs::create_dir_all(parent)?;
370 }
371
372 let encoded = if is_toml_path(path) {
373 toml::to_string_pretty(cfg)
374 .map_err(|error| ConfigError::Parse(format!("toml encode error: {error}")))?
375 } else {
376 serde_json::to_string_pretty(cfg)?
377 };
378 write_atomic(path, &encoded)
379}
380
381pub fn write_user_template(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
382 validate(cfg).map_err(ConfigError::Validation)?;
383
384 if let Some(parent) = path.parent() {
385 std::fs::create_dir_all(parent)?;
386 }
387
388 if is_toml_path(path) {
389 return write_user_template_toml(cfg, path);
390 }
391
392 let roots_section = json5_path_array_section(&cfg.discovery_roots);
393 let excluded_roots_section = json5_path_array_section(&cfg.discovery_exclude_roots);
394
395 let mut text = String::new();
396 text.push_str("{\n");
397 text.push_str(" // Nex config (comments are allowed).\n");
398 text.push_str(" //\n");
399 text.push_str(" // How to use this file:\n");
400 text.push_str(" // - Edit values and save.\n");
401 text.push_str(" // - Most settings apply automatically within about 1 second.\n");
402 text.push_str(" // - Restart required after changing hotkey or index_db_path.\n");
403 text.push_str(" // - Keep strings in double quotes.\n");
404 text.push_str(
405 " // - Use double backslashes for Windows paths (C:\\\\Users\\\\Admin\\\\...).\n",
406 );
407 text.push_str(" // - true/false and numbers must not be quoted.\n");
408 text.push_str(" // - This file is the active config while using .json format.\n");
409 text.push_str(" //\n");
410 text.push_str(" // Quick setup:\n");
411 text.push_str(" // 1) Keep exactly ONE `hotkey` line uncommented.\n");
412 text.push_str(" // 2) Save file.\n");
413 text.push_str(" // 3) Restart only if you changed hotkey/index_db_path.\n");
414 text.push_str(" //\n");
415 text.push_str(" // Safer Windows-friendly hotkeys (uncomment one if you prefer):\n");
416
417 for option in &cfg.hotkey_recommended {
418 if option != &cfg.hotkey {
419 text.push_str(" // \"hotkey\": ");
420 text.push_str(&json_string(option));
421 text.push_str(",\n");
422 }
423 }
424
425 text.push_str(
426 " // Avoid common OS-reserved/conflicting shortcuts like Win+..., Alt+Tab, Ctrl+Esc.\n",
427 );
428 text.push_str(" \"hotkey\": ");
429 text.push_str(&json_string(&cfg.hotkey));
430 text.push_str(",\n");
431 text.push_str(" // Start Nex automatically when you sign in (true/false)\n");
432 text.push_str(" \"launch_at_startup\": ");
433 text.push_str(if cfg.launch_at_startup {
434 "true"
435 } else {
436 "false"
437 });
438 text.push_str(",\n\n");
439
440 text.push_str(" // Optional tuning:\n");
441 text.push_str(" // Number of results shown per query (valid range: 5..100)\n");
442 text.push_str(" \"max_results\": ");
443 text.push_str(&cfg.max_results.to_string());
444 text.push_str(",\n\n");
445
446 text.push_str(" // Optional: folders scanned for local files.\n");
447 text.push_str(" // Add/remove paths as needed.\n");
448 text.push_str(" \"discovery_roots\": ");
449 text.push_str(&roots_section);
450 text.push_str(",\n\n");
451 text.push_str(" // Optional: folders to exclude from local-file discovery.\n");
452 text.push_str(" // Any file/folder under these roots is ignored.\n");
453 text.push_str(" // Nex also skips common system/temp/dev-noise paths automatically\n");
454 text.push_str(
455 " // (for example: Windows, Program Files, AppData, node_modules, .git, __pycache__).\n",
456 );
457 text.push_str(
458 " // These built-in exclusions affect file/folder indexing only, not app discovery.\n",
459 );
460 text.push_str(" \"discovery_exclude_roots\": ");
461 text.push_str(&excluded_roots_section);
462 text.push_str(",\n\n");
463 text.push_str(" // Use Windows Search index for file/folder discovery when available.\n");
464 text.push_str(" \"windows_search_enabled\": ");
465 text.push_str(if cfg.windows_search_enabled {
466 "true"
467 } else {
468 "false"
469 });
470 text.push_str(",\n");
471 text.push_str(" // Fall back to direct filesystem scan when Windows Search is unavailable.\n");
472 text.push_str(" \"windows_search_fallback_filesystem\": ");
473 text.push_str(if cfg.windows_search_fallback_filesystem {
474 "true"
475 } else {
476 "false"
477 });
478 text.push_str(",\n\n");
479 text.push_str(" // Toggle file and folder visibility in results.\n");
480 text.push_str(" \"show_files\": ");
481 text.push_str(if cfg.show_files { "true" } else { "false" });
482 text.push_str(",\n");
483 text.push_str(" \"show_folders\": ");
484 text.push_str(if cfg.show_folders { "true" } else { "false" });
485 text.push_str(",\n\n");
486
487 text.push_str(" // Search mode default: all | apps | files | actions | clipboard\n");
488 text.push_str(" \"search_mode_default\": ");
489 text.push_str(&json_string(match cfg.search_mode_default {
490 SearchMode::All => "all",
491 SearchMode::Apps => "apps",
492 SearchMode::Files => "files",
493 SearchMode::Actions => "actions",
494 SearchMode::Clipboard => "clipboard",
495 }));
496 text.push_str(",\n");
497 text.push_str(
498 " // Enable query operators like kind:, modified:, created:, AND/OR/NOT and -term\n",
499 );
500 text.push_str(" \"search_dsl_enabled\": ");
501 text.push_str(if cfg.search_dsl_enabled {
502 "true"
503 } else {
504 "false"
505 });
506 text.push_str(",\n");
507 text.push_str(" // Delay query execution while typing for smoother UI updates\n");
508 text.push_str(" \"search_query_results_with_delay\": ");
509 text.push_str(if cfg.search_query_results_with_delay {
510 "true"
511 } else {
512 "false"
513 });
514 text.push_str(",\n");
515 text.push_str(" // Typing delay in milliseconds (valid range: 10..2000)\n");
516 text.push_str(" \"search_delay_time_ms\": ");
517 text.push_str(&cfg.search_delay_time_ms.to_string());
518 text.push_str(",\n");
519 text.push_str(" // Enable command mode uninstall actions (e.g. > uninstall appname)\n");
520 text.push_str(" \"uninstall_actions_enabled\": ");
521 text.push_str(if cfg.uninstall_actions_enabled {
522 "true"
523 } else {
524 "false"
525 });
526 text.push_str(",\n\n");
527 text.push_str(" // Web search in command mode (press > then type your query)\n");
528 text.push_str(" // Default is google for most users.\n");
529 text.push_str(
530 " // Options: google | duckduckgo | bing | brave | startpage | ecosia | yahoo | custom\n",
531 );
532 text.push_str(" \"web_search_provider\": ");
533 text.push_str(&json_string(match cfg.web_search_provider {
534 WebSearchProvider::Duckduckgo => "duckduckgo",
535 WebSearchProvider::Google => "google",
536 WebSearchProvider::Bing => "bing",
537 WebSearchProvider::Brave => "brave",
538 WebSearchProvider::Startpage => "startpage",
539 WebSearchProvider::Ecosia => "ecosia",
540 WebSearchProvider::Yahoo => "yahoo",
541 WebSearchProvider::Custom => "custom",
542 }));
543 text.push_str(",\n");
544 text.push_str(" // Used only when provider is custom. Must include {query}.\n");
545 text.push_str(" // Example: \"https://example.com/search?q={query}\"\n");
546 text.push_str(" \"web_search_custom_template\": ");
547 text.push_str(&json_string(&cfg.web_search_custom_template));
548 text.push_str(",\n\n");
549
550 text.push_str(" // Clipboard history provider settings\n");
551 text.push_str(" \"clipboard_enabled\": ");
552 text.push_str(if cfg.clipboard_enabled {
553 "true"
554 } else {
555 "false"
556 });
557 text.push_str(",\n");
558 text.push_str(" // Retention in minutes (valid range: 5..43200)\n");
559 text.push_str(" \"clipboard_retention_minutes\": ");
560 text.push_str(&cfg.clipboard_retention_minutes.to_string());
561 text.push_str(",\n");
562 text.push_str(
563 " // Substring patterns that should be skipped when capturing clipboard entries\n",
564 );
565 text.push_str(" \"clipboard_exclude_sensitive_patterns\": [\n");
566 for (idx, pattern) in cfg.clipboard_exclude_sensitive_patterns.iter().enumerate() {
567 text.push_str(" ");
568 text.push_str(&json_string(pattern));
569 if idx + 1 != cfg.clipboard_exclude_sensitive_patterns.len() {
570 text.push(',');
571 }
572 text.push('\n');
573 }
574 text.push_str(" ],\n\n");
575
576 text.push_str(" // Plugin SDK settings\n");
577 text.push_str(" \"plugins_enabled\": ");
578 text.push_str(if cfg.plugins_enabled { "true" } else { "false" });
579 text.push_str(",\n");
580 text.push_str(" // Keep safe mode true to prevent plugin command execution.\n");
581 text.push_str(" \"plugins_safe_mode\": ");
582 text.push_str(if cfg.plugins_safe_mode {
583 "true"
584 } else {
585 "false"
586 });
587 text.push_str(",\n");
588 text.push_str(" // Game Mode: suppress the launcher hotkey while a likely game/fullscreen app is active.\n");
589 text.push_str(" \"game_mode_enabled\": ");
590 text.push_str(if cfg.game_mode_enabled {
591 "true"
592 } else {
593 "false"
594 });
595 text.push_str(",\n");
596 text.push_str(" \"plugin_paths\": [\n");
597 for (idx, path) in cfg.plugin_paths.iter().enumerate() {
598 text.push_str(" ");
599 text.push_str(&json_string(&path.to_string_lossy()));
600 if idx + 1 != cfg.plugin_paths.len() {
601 text.push(',');
602 }
603 text.push('\n');
604 }
605 text.push_str(" ],\n\n");
606
607 text.push_str(" // Runtime performance targets\n");
608 text.push_str(" // cache trim after hide in milliseconds (valid range: 100..10000)\n");
609 text.push_str(" \"idle_cache_trim_ms\": ");
610 text.push_str(&cfg.idle_cache_trim_ms.to_string());
611 text.push_str(",\n");
612 text.push_str(" // active memory target in MB (valid range: 20..512)\n");
613 text.push_str(" \"active_memory_target_mb\": ");
614 text.push_str(&cfg.active_memory_target_mb.to_string());
615 text.push_str(",\n");
616 text.push_str(" // Maximum indexed file/folder items retained in database discovery pass\n");
617 text.push_str(" \"index_max_items_total\": ");
618 text.push_str(&cfg.index_max_items_total.to_string());
619 text.push_str(",\n");
620 text.push_str(" // Maximum indexed file/folder items retained per discovery root\n");
621 text.push_str(" \"index_max_items_per_root\": ");
622 text.push_str(&cfg.index_max_items_per_root.to_string());
623 text.push_str(",\n");
624 text.push_str(" // Runtime candidate budget for per-query file/folder retrieval\n");
625 text.push_str(" \"index_max_items_per_query_seed\": ");
626 text.push_str(&cfg.index_max_items_per_query_seed.to_string());
627 text.push('\n');
628 text.push_str("}\n");
629
630 std::fs::write(path, text)?;
631 Ok(())
632}
633
634fn write_user_template_toml(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
635 let roots_section = toml_path_array_section(&cfg.discovery_roots);
636 let excluded_roots_section = toml_path_array_section(&cfg.discovery_exclude_roots);
637 let plugin_paths_section = toml_path_array_section(&cfg.plugin_paths);
638 let clipboard_patterns_section =
639 toml_string_array_section(&cfg.clipboard_exclude_sensitive_patterns);
640
641 let mut text = String::new();
642 text.push_str("# Nex config (TOML format).\n");
643 text.push_str("#\n");
644 text.push_str("# How to use this file:\n");
645 text.push_str("# - Edit values and save.\n");
646 text.push_str("# - Most settings apply automatically within about 1 second.\n");
647 text.push_str("# - Restart required after changing hotkey or index_db_path.\n");
648 text.push_str("# - Strings must be in quotes (example: hotkey = \"Ctrl+Space\").\n");
649 text.push_str("# - Use double backslashes for Windows paths (C:\\\\Users\\\\Admin\\\\...).\n");
650 text.push_str("# - true/false and numbers are not quoted.\n");
651 text.push_str("# - This is the active config. Legacy config.json is kept only as backup.\n");
652 text.push_str("#\n");
653 text.push_str("# Quick setup:\n");
654 text.push_str("# 1) Keep exactly ONE hotkey value.\n");
655 text.push_str("# 2) Save file.\n");
656 text.push_str("# 3) Restart only if you changed hotkey/index_db_path.\n");
657 text.push_str("#\n");
658 text.push_str("# Safer Windows-friendly hotkeys you can use:\n");
659 for option in &cfg.hotkey_recommended {
660 text.push_str("# hotkey = ");
661 text.push_str(&json_string(option));
662 text.push('\n');
663 }
664 text.push_str("# Avoid common OS-reserved shortcuts like Win+..., Alt+Tab, Ctrl+Esc.\n");
665 text.push_str("hotkey = ");
666 text.push_str(&json_string(&cfg.hotkey));
667 text.push_str("\n\n");
668
669 text.push_str("# Start Nex automatically when you sign in (true/false)\n");
670 text.push_str("launch_at_startup = ");
671 text.push_str(if cfg.launch_at_startup {
672 "true"
673 } else {
674 "false"
675 });
676 text.push_str("\n\n");
677
678 text.push_str("# Number of results shown per query (valid range: 5..100)\n");
679 text.push_str("max_results = ");
680 text.push_str(&cfg.max_results.to_string());
681 text.push_str("\n\n");
682
683 text.push_str("# Folders scanned for local files.\n");
684 text.push_str("discovery_roots = ");
685 text.push_str(&roots_section);
686 text.push_str("\n\n");
687 text.push_str("# Folders excluded from local-file discovery.\n");
688 text.push_str("# Nex also skips common system/temp/dev-noise paths automatically\n");
689 text.push_str(
690 "# (for example: Windows, Program Files, AppData, node_modules, .git, __pycache__).\n",
691 );
692 text.push_str("# Built-in exclusions affect file/folder indexing only, not app discovery.\n");
693 text.push_str("discovery_exclude_roots = ");
694 text.push_str(&excluded_roots_section);
695 text.push_str("\n\n");
696
697 text.push_str("# Use Windows Search index for file/folder discovery when available.\n");
698 text.push_str("windows_search_enabled = ");
699 text.push_str(if cfg.windows_search_enabled {
700 "true"
701 } else {
702 "false"
703 });
704 text.push('\n');
705 text.push_str("# Fall back to direct filesystem scan when Windows Search is unavailable.\n");
706 text.push_str("windows_search_fallback_filesystem = ");
707 text.push_str(if cfg.windows_search_fallback_filesystem {
708 "true"
709 } else {
710 "false"
711 });
712 text.push_str("\n\n");
713
714 text.push_str("# Toggle file and folder visibility in results.\n");
715 text.push_str("show_files = ");
716 text.push_str(if cfg.show_files { "true" } else { "false" });
717 text.push('\n');
718 text.push_str("show_folders = ");
719 text.push_str(if cfg.show_folders { "true" } else { "false" });
720 text.push_str("\n\n");
721
722 text.push_str("# Search mode default: all | apps | files | actions | clipboard\n");
723 text.push_str("search_mode_default = ");
724 text.push_str(&json_string(match cfg.search_mode_default {
725 SearchMode::All => "all",
726 SearchMode::Apps => "apps",
727 SearchMode::Files => "files",
728 SearchMode::Actions => "actions",
729 SearchMode::Clipboard => "clipboard",
730 }));
731 text.push('\n');
732 text.push_str(
733 "# Enable query operators like kind:, modified:, created:, AND/OR/NOT and -term\n",
734 );
735 text.push_str("search_dsl_enabled = ");
736 text.push_str(if cfg.search_dsl_enabled {
737 "true"
738 } else {
739 "false"
740 });
741 text.push('\n');
742 text.push_str("# Delay query execution while typing for smoother UI updates\n");
743 text.push_str("search_query_results_with_delay = ");
744 text.push_str(if cfg.search_query_results_with_delay {
745 "true"
746 } else {
747 "false"
748 });
749 text.push('\n');
750 text.push_str("# Typing delay in milliseconds (valid range: 10..2000)\n");
751 text.push_str("search_delay_time_ms = ");
752 text.push_str(&cfg.search_delay_time_ms.to_string());
753 text.push('\n');
754 text.push_str("# Enable command mode uninstall actions (e.g. > uninstall appname)\n");
755 text.push_str("uninstall_actions_enabled = ");
756 text.push_str(if cfg.uninstall_actions_enabled {
757 "true"
758 } else {
759 "false"
760 });
761 text.push_str("\n\n");
762
763 text.push_str("# Web search in command mode (press > then type your query)\n");
764 text.push_str("# Default is google for most users.\n");
765 text.push_str(
766 "# Options: google | duckduckgo | bing | brave | startpage | ecosia | yahoo | custom\n",
767 );
768 text.push_str("web_search_provider = ");
769 text.push_str(&json_string(match cfg.web_search_provider {
770 WebSearchProvider::Duckduckgo => "duckduckgo",
771 WebSearchProvider::Google => "google",
772 WebSearchProvider::Bing => "bing",
773 WebSearchProvider::Brave => "brave",
774 WebSearchProvider::Startpage => "startpage",
775 WebSearchProvider::Ecosia => "ecosia",
776 WebSearchProvider::Yahoo => "yahoo",
777 WebSearchProvider::Custom => "custom",
778 }));
779 text.push('\n');
780 text.push_str("# Used only when provider is custom. Must include {query}.\n");
781 text.push_str("# Example: \"https://example.com/search?q={query}\"\n");
782 text.push_str("web_search_custom_template = ");
783 text.push_str(&json_string(&cfg.web_search_custom_template));
784 text.push_str("\n\n");
785
786 text.push_str("# Clipboard history provider settings\n");
787 text.push_str("clipboard_enabled = ");
788 text.push_str(if cfg.clipboard_enabled {
789 "true"
790 } else {
791 "false"
792 });
793 text.push('\n');
794 text.push_str("# Retention in minutes (valid range: 5..43200)\n");
795 text.push_str("clipboard_retention_minutes = ");
796 text.push_str(&cfg.clipboard_retention_minutes.to_string());
797 text.push('\n');
798 text.push_str("# Substring patterns skipped when capturing clipboard entries\n");
799 text.push_str("clipboard_exclude_sensitive_patterns = ");
800 text.push_str(&clipboard_patterns_section);
801 text.push_str("\n\n");
802
803 text.push_str("# Plugin SDK settings\n");
804 text.push_str("plugins_enabled = ");
805 text.push_str(if cfg.plugins_enabled { "true" } else { "false" });
806 text.push('\n');
807 text.push_str("# Keep safe mode true to prevent plugin command execution.\n");
808 text.push_str("plugins_safe_mode = ");
809 text.push_str(if cfg.plugins_safe_mode {
810 "true"
811 } else {
812 "false"
813 });
814 text.push('\n');
815 text.push_str(
816 "# Game Mode: suppress the launcher hotkey while a likely game/fullscreen app is active.\n",
817 );
818 text.push_str("game_mode_enabled = ");
819 text.push_str(if cfg.game_mode_enabled {
820 "true"
821 } else {
822 "false"
823 });
824 text.push('\n');
825 text.push_str("plugin_paths = ");
826 text.push_str(&plugin_paths_section);
827 text.push_str("\n\n");
828
829 text.push_str("# Runtime performance targets\n");
830 text.push_str("# cache trim after hide in milliseconds (valid range: 100..10000)\n");
831 text.push_str("idle_cache_trim_ms = ");
832 text.push_str(&cfg.idle_cache_trim_ms.to_string());
833 text.push('\n');
834 text.push_str("# active memory target in MB (valid range: 20..512)\n");
835 text.push_str("active_memory_target_mb = ");
836 text.push_str(&cfg.active_memory_target_mb.to_string());
837 text.push('\n');
838 text.push_str("# Maximum indexed file/folder items retained in discovery pass\n");
839 text.push_str("index_max_items_total = ");
840 text.push_str(&cfg.index_max_items_total.to_string());
841 text.push('\n');
842 text.push_str("# Maximum indexed file/folder items retained per discovery root\n");
843 text.push_str("index_max_items_per_root = ");
844 text.push_str(&cfg.index_max_items_per_root.to_string());
845 text.push('\n');
846 text.push_str("# Runtime candidate budget for per-query file/folder retrieval\n");
847 text.push_str("index_max_items_per_query_seed = ");
848 text.push_str(&cfg.index_max_items_per_query_seed.to_string());
849 text.push('\n');
850
851 std::fs::write(path, text)?;
852 Ok(())
853}
854
855pub fn validate(cfg: &Config) -> Result<(), String> {
856 if cfg.max_results < 5 || cfg.max_results > 100 {
857 return Err("max_results out of range".into());
858 }
859
860 if cfg.index_db_path.as_os_str().is_empty() {
861 return Err("index_db_path is required".into());
862 }
863
864 if cfg.config_path.as_os_str().is_empty() {
865 return Err("config_path is required".into());
866 }
867
868 if cfg.hotkey.trim().is_empty() {
869 return Err("hotkey is required".into());
870 }
871
872 if cfg.clipboard_retention_minutes < 5 || cfg.clipboard_retention_minutes > 43_200 {
873 return Err("clipboard_retention_minutes out of range".into());
874 }
875
876 if cfg.idle_cache_trim_ms < 100 || cfg.idle_cache_trim_ms > 10_000 {
877 return Err("idle_cache_trim_ms out of range".into());
878 }
879
880 if cfg.active_memory_target_mb < 20 || cfg.active_memory_target_mb > 512 {
881 return Err("active_memory_target_mb out of range".into());
882 }
883 if cfg.search_delay_time_ms < 10 || cfg.search_delay_time_ms > 2_000 {
884 return Err("search_delay_time_ms out of range".into());
885 }
886
887 if cfg.index_max_items_total < 10_000 || cfg.index_max_items_total > 2_000_000 {
888 return Err("index_max_items_total out of range".into());
889 }
890
891 if cfg.index_max_items_per_root < 1_000 || cfg.index_max_items_per_root > 1_000_000 {
892 return Err("index_max_items_per_root out of range".into());
893 }
894
895 if cfg.index_max_items_per_query_seed < 250 || cfg.index_max_items_per_query_seed > 200_000 {
896 return Err("index_max_items_per_query_seed out of range".into());
897 }
898
899 if cfg.index_max_items_per_root > cfg.index_max_items_total {
900 return Err("index_max_items_per_root must be <= index_max_items_total".into());
901 }
902
903 if cfg.web_search_provider == WebSearchProvider::Custom {
904 let template = cfg.web_search_custom_template.trim();
905 if template.is_empty() {
906 return Err(
907 "web_search_custom_template is required when web_search_provider=custom".into(),
908 );
909 }
910 if !template.contains("{query}") {
911 return Err("web_search_custom_template must include {query} placeholder".into());
912 }
913 }
914
915 if cfg
916 .discovery_roots
917 .iter()
918 .any(|root| root.as_os_str().is_empty())
919 {
920 return Err("discovery_roots contains an empty path".into());
921 }
922
923 if cfg
924 .discovery_exclude_roots
925 .iter()
926 .any(|root| root.as_os_str().is_empty())
927 {
928 return Err("discovery_exclude_roots contains an empty path".into());
929 }
930
931 if cfg
932 .plugin_paths
933 .iter()
934 .any(|path| path.as_os_str().is_empty())
935 {
936 return Err("plugin_paths contains an empty path".into());
937 }
938
939 if cfg
940 .clipboard_exclude_sensitive_patterns
941 .iter()
942 .any(|pattern| pattern.trim().is_empty())
943 {
944 return Err("clipboard_exclude_sensitive_patterns contains an empty pattern".into());
945 }
946
947 crate::settings::validate_hotkey(&cfg.hotkey)
948 .map_err(|error| format!("hotkey is invalid: {error}"))?;
949
950 if cfg.version == 0 {
951 return Err("version must be >= 1".into());
952 }
953
954 Ok(())
955}
956
957fn write_atomic(path: &Path, encoded: &str) -> Result<(), ConfigError> {
958 let parent = path.parent().unwrap_or_else(|| Path::new("."));
959 let ts = SystemTime::now()
960 .duration_since(UNIX_EPOCH)
961 .map(|d| d.as_nanos())
962 .unwrap_or(0);
963 let temp_path = parent.join(format!(".nex-config-{ts}.tmp"));
964 let backup_path = parent.join(".nex-config.backup");
965
966 std::fs::write(&temp_path, encoded)?;
967
968 if backup_path.exists() {
969 let _ = std::fs::remove_file(&backup_path);
970 }
971 if path.exists() {
972 std::fs::rename(path, &backup_path)?;
973 }
974
975 match std::fs::rename(&temp_path, path) {
976 Ok(()) => {
977 if backup_path.exists() {
978 let _ = std::fs::remove_file(&backup_path);
979 }
980 Ok(())
981 }
982 Err(error) => {
983 if backup_path.exists() {
984 let _ = std::fs::rename(&backup_path, path);
985 }
986 let _ = std::fs::remove_file(&temp_path);
987 Err(ConfigError::Io(error))
988 }
989 }
990}
991
992fn json5_path_array_section(paths: &[PathBuf]) -> String {
993 let body = paths
994 .iter()
995 .map(|path| format!(" {}", json_string(&path.to_string_lossy())))
996 .collect::<Vec<_>>()
997 .join(",\n");
998
999 if body.is_empty() {
1000 "[]".to_string()
1001 } else {
1002 format!("[\n{body}\n ]")
1003 }
1004}
1005
1006fn toml_path_array_section(paths: &[PathBuf]) -> String {
1007 let values = paths
1008 .iter()
1009 .map(|path| path.to_string_lossy().to_string())
1010 .collect::<Vec<_>>();
1011 toml_string_array_section(&values)
1012}
1013
1014fn toml_string_array_section(values: &[String]) -> String {
1015 if values.is_empty() {
1016 return "[]".to_string();
1017 }
1018
1019 let body = values
1020 .iter()
1021 .map(|value| format!(" {}", json_string(value)))
1022 .collect::<Vec<_>>()
1023 .join(",\n");
1024 format!("[\n{body},\n]")
1025}
1026
1027fn default_discovery_roots() -> Vec<PathBuf> {
1028 #[cfg(target_os = "windows")]
1029 {
1030 if let Some(profile_root) = windows_user_profile_root() {
1031 return vec![profile_root];
1032 }
1033 }
1034
1035 Vec::new()
1036}
1037
1038fn default_discovery_exclude_roots() -> Vec<PathBuf> {
1039 #[cfg(target_os = "windows")]
1040 {
1041 if let Some(profile_root) = windows_user_profile_root() {
1042 return vec![
1043 profile_root.join("AppData").join("Local").join("Temp"),
1044 profile_root
1045 .join("AppData")
1046 .join("Local")
1047 .join("Microsoft")
1048 .join("Windows")
1049 .join("INetCache"),
1050 ];
1051 }
1052 }
1053
1054 Vec::new()
1055}
1056
1057#[cfg(target_os = "windows")]
1058fn windows_user_profile_root() -> Option<PathBuf> {
1059 if let Ok(user_profile) = std::env::var("USERPROFILE") {
1060 let trimmed = user_profile.trim();
1061 if !trimmed.is_empty() {
1062 return Some(PathBuf::from(trimmed));
1063 }
1064 }
1065
1066 let home_drive = std::env::var("HOMEDRIVE").ok();
1067 let home_path = std::env::var("HOMEPATH").ok();
1068 if let (Some(drive), Some(path)) = (home_drive, home_path) {
1069 let combined = format!("{}{}", drive.trim(), path.trim());
1070 let trimmed = combined.trim();
1071 if !trimmed.is_empty() {
1072 return Some(PathBuf::from(trimmed));
1073 }
1074 }
1075
1076 None
1077}
1078
1079fn default_for_path(path: &Path) -> Config {
1080 let mut cfg = Config::default();
1081 cfg.config_path = path.to_path_buf();
1082 if cfg.index_db_path == Config::default().index_db_path {
1083 cfg.index_db_path = path
1084 .parent()
1085 .unwrap_or_else(|| Path::new("."))
1086 .join("index.sqlite3");
1087 }
1088 cfg
1089}
1090
1091fn apply_migrations(cfg: &mut Config, raw: &str) -> bool {
1092 let mut changed = false;
1093 let source_version = cfg.version.max(1);
1094
1095 if cfg.version < CURRENT_CONFIG_VERSION {
1096 cfg.version = CURRENT_CONFIG_VERSION;
1097 changed = true;
1098 }
1099
1100 if source_version < 2 {
1101 let had_idle_key = raw_has_key(raw, "idle_cache_trim_ms");
1102 let had_active_mem_key = raw_has_key(raw, "active_memory_target_mb");
1103 if !had_idle_key || cfg.idle_cache_trim_ms == LEGACY_IDLE_CACHE_TRIM_MS_V1 {
1104 cfg.idle_cache_trim_ms = Config::default().idle_cache_trim_ms;
1105 changed = true;
1106 }
1107 if !had_active_mem_key || cfg.active_memory_target_mb == LEGACY_ACTIVE_MEMORY_TARGET_MB_V1 {
1108 cfg.active_memory_target_mb = Config::default().active_memory_target_mb;
1109 changed = true;
1110 }
1111 }
1112
1113 if source_version < 3 {
1114 if !raw_has_key(raw, "web_search_provider") {
1115 cfg.web_search_provider = Config::default().web_search_provider;
1116 changed = true;
1117 }
1118 if !raw_has_key(raw, "web_search_custom_template") {
1119 cfg.web_search_custom_template = Config::default().web_search_custom_template;
1120 changed = true;
1121 }
1122 }
1123
1124 if source_version < 4 {
1125 if !raw_has_key(raw, "windows_search_enabled") {
1126 cfg.windows_search_enabled = Config::default().windows_search_enabled;
1127 changed = true;
1128 }
1129 if !raw_has_key(raw, "windows_search_fallback_filesystem") {
1130 cfg.windows_search_fallback_filesystem =
1131 Config::default().windows_search_fallback_filesystem;
1132 changed = true;
1133 }
1134 }
1135
1136 if source_version < 5 {
1137 if !raw_has_key(raw, "show_files") {
1138 cfg.show_files = Config::default().show_files;
1139 changed = true;
1140 }
1141 if !raw_has_key(raw, "show_folders") {
1142 cfg.show_folders = Config::default().show_folders;
1143 changed = true;
1144 }
1145 }
1146
1147 if source_version < 6 && !raw_has_key(raw, "uninstall_actions_enabled") {
1148 cfg.uninstall_actions_enabled = Config::default().uninstall_actions_enabled;
1149 changed = true;
1150 }
1151
1152 if source_version < 7 {
1153 if !raw_has_key(raw, "index_max_items_total") {
1154 cfg.index_max_items_total = Config::default().index_max_items_total;
1155 changed = true;
1156 }
1157 if !raw_has_key(raw, "index_max_items_per_root") {
1158 cfg.index_max_items_per_root = Config::default().index_max_items_per_root;
1159 changed = true;
1160 }
1161 if !raw_has_key(raw, "index_max_items_per_query_seed") {
1162 cfg.index_max_items_per_query_seed = Config::default().index_max_items_per_query_seed;
1163 changed = true;
1164 }
1165 }
1166
1167 if source_version < 10 && !raw_has_key(raw, "game_mode_enabled") {
1168 cfg.game_mode_enabled = Config::default().game_mode_enabled;
1169 changed = true;
1170 }
1171
1172 if source_version < 9 {
1173 if !raw_has_key(raw, "search_query_results_with_delay") {
1174 cfg.search_query_results_with_delay = Config::default().search_query_results_with_delay;
1175 changed = true;
1176 }
1177 if !raw_has_key(raw, "search_delay_time_ms") {
1178 cfg.search_delay_time_ms = Config::default().search_delay_time_ms;
1179 changed = true;
1180 }
1181 }
1182
1183 if TEMPLATE_REQUIRED_KEYS
1184 .iter()
1185 .any(|key| !raw_has_key(raw, key))
1186 {
1187 changed = true;
1188 }
1189
1190 if raw.contains(LEGACY_APP_DISPLAY_NAME) || raw.contains(LEGACY_APP_DIR_NAME_UNIX) {
1191 changed = true;
1192 }
1193
1194 changed
1195}
1196
1197fn persist_migrated_config(
1198 cfg: &Config,
1199 path: &Path,
1200 original_raw: &str,
1201 source_version: u32,
1202 source_path: &Path,
1203) -> Result<(), ConfigError> {
1204 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1205 std::fs::create_dir_all(parent)?;
1206
1207 let stamp = SystemTime::now()
1208 .duration_since(UNIX_EPOCH)
1209 .map(|d| d.as_secs())
1210 .unwrap_or(0);
1211 let backup_ext = source_path
1212 .extension()
1213 .and_then(|ext| ext.to_str())
1214 .filter(|ext| !ext.trim().is_empty())
1215 .unwrap_or("txt");
1216 let backup_path = parent.join(format!(
1217 "config.v{}-backup-{}.{}",
1218 source_version.max(1),
1219 stamp,
1220 backup_ext
1221 ));
1222 std::fs::write(&backup_path, original_raw)?;
1223 write_user_template(cfg, path)
1224}
1225
1226fn raw_has_key(raw: &str, key: &str) -> bool {
1227 let quoted = format!("\"{key}\"");
1228 if raw.contains("ed) {
1229 return true;
1230 }
1231 let toml_key = format!("{key} =");
1232 if raw.contains(&toml_key) {
1233 return true;
1234 }
1235 let bare = format!("{key}:");
1236 raw.contains(&bare)
1237}
1238
1239fn parse_text(raw: &str) -> Result<Config, ConfigError> {
1240 match toml::from_str::<Config>(raw) {
1241 Ok(cfg) => Ok(cfg),
1242 Err(toml_err) => match serde_json::from_str::<Config>(raw) {
1243 Ok(cfg) => Ok(cfg),
1244 Err(json_err) => match json5::from_str::<Config>(raw) {
1245 Ok(cfg) => Ok(cfg),
1246 Err(json5_err) => Err(ConfigError::Parse(format!(
1247 "invalid config format. toml error: {toml_err}; json error: {json_err}; json5 error: {json5_err}"
1248 ))),
1249 },
1250 },
1251 }
1252}
1253
1254fn json_string(value: &str) -> String {
1255 serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
1256}
1257
1258#[cfg(target_os = "windows")]
1259fn windows_app_data_dir(app_dir_name: &str) -> Option<PathBuf> {
1260 if let Ok(app_data) = std::env::var("APPDATA") {
1261 return Some(PathBuf::from(app_data).join(app_dir_name));
1262 }
1263
1264 if let Ok(user_profile) = std::env::var("USERPROFILE") {
1265 return Some(
1266 PathBuf::from(user_profile)
1267 .join("AppData")
1268 .join("Roaming")
1269 .join(app_dir_name),
1270 );
1271 }
1272
1273 None
1274}
1275
1276#[cfg(not(target_os = "windows"))]
1277fn unix_app_data_dir(app_dir_name: &str) -> Option<PathBuf> {
1278 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1279 return Some(PathBuf::from(xdg).join(app_dir_name));
1280 }
1281
1282 if let Ok(home) = std::env::var("HOME") {
1283 return Some(PathBuf::from(home).join(".config").join(app_dir_name));
1284 }
1285
1286 None
1287}
1288
1289fn migrate_legacy_app_data_dir(preferred: PathBuf, legacy: Option<PathBuf>) -> PathBuf {
1290 let Some(legacy) = legacy else {
1291 return preferred;
1292 };
1293
1294 if legacy == preferred || !legacy.exists() {
1295 return preferred;
1296 }
1297
1298 if !preferred.exists() {
1299 if let Some(parent) = preferred.parent() {
1300 let _ = std::fs::create_dir_all(parent);
1301 }
1302 return match std::fs::rename(&legacy, &preferred) {
1303 Ok(()) => preferred,
1304 Err(_) => legacy,
1305 };
1306 }
1307
1308 match move_missing_entries(&legacy, &preferred) {
1309 Ok(()) => preferred,
1310 Err(_) => {
1311 if app_data_dir_has_state(&preferred) || !app_data_dir_has_state(&legacy) {
1312 preferred
1313 } else {
1314 legacy
1315 }
1316 }
1317 }
1318}
1319
1320fn move_missing_entries(from: &Path, to: &Path) -> std::io::Result<()> {
1321 std::fs::create_dir_all(to)?;
1322 for entry in std::fs::read_dir(from)? {
1323 let entry = entry?;
1324 let target = to.join(entry.file_name());
1325 if target.exists() {
1326 continue;
1327 }
1328 std::fs::rename(entry.path(), target)?;
1329 }
1330
1331 let is_empty = std::fs::read_dir(from)?.next().transpose()?.is_none();
1332 if is_empty {
1333 let _ = std::fs::remove_dir(from);
1334 }
1335 Ok(())
1336}
1337
1338fn app_data_dir_has_state(path: &Path) -> bool {
1339 [CONFIG_FILE_NAME, LEGACY_CONFIG_FILE_NAME, "index.sqlite3"]
1340 .iter()
1341 .any(|file_name| path.join(file_name).exists())
1342}
1343
1344fn rewrite_managed_paths_to_current_app_dir(cfg: &mut Config) -> bool {
1345 let current_dir = stable_app_data_dir();
1346
1347 #[cfg(target_os = "windows")]
1348 let legacy_dir = windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS);
1349 #[cfg(not(target_os = "windows"))]
1350 let legacy_dir = unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX);
1351
1352 let Some(legacy_dir) = legacy_dir else {
1353 return false;
1354 };
1355 if legacy_dir == current_dir {
1356 return false;
1357 }
1358
1359 let mut changed = false;
1360
1361 if let Some(rebased) = rebase_managed_path(&cfg.index_db_path, &legacy_dir, ¤t_dir) {
1362 cfg.index_db_path = rebased;
1363 changed = true;
1364 }
1365
1366 for plugin_path in &mut cfg.plugin_paths {
1367 if let Some(rebased) = rebase_managed_path(plugin_path, &legacy_dir, ¤t_dir) {
1368 *plugin_path = rebased;
1369 changed = true;
1370 }
1371 }
1372
1373 changed
1374}
1375
1376fn rebase_managed_path(path: &Path, from_root: &Path, to_root: &Path) -> Option<PathBuf> {
1377 let relative = path.strip_prefix(from_root).ok()?;
1378 Some(to_root.join(relative))
1379}