Skip to main content

nex_core/
config.rs

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(&quoted) {
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, &current_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, &current_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}