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("  // (for example: Windows, Program Files, AppData, node_modules, .git, __pycache__).\n");
455    text.push_str("  // These built-in exclusions affect file/folder indexing only, not app discovery.\n");
456    text.push_str("  \"discovery_exclude_roots\": ");
457    text.push_str(&excluded_roots_section);
458    text.push_str(",\n\n");
459    text.push_str("  // Use Windows Search index for file/folder discovery when available.\n");
460    text.push_str("  \"windows_search_enabled\": ");
461    text.push_str(if cfg.windows_search_enabled {
462        "true"
463    } else {
464        "false"
465    });
466    text.push_str(",\n");
467    text.push_str("  // Fall back to direct filesystem scan when Windows Search is unavailable.\n");
468    text.push_str("  \"windows_search_fallback_filesystem\": ");
469    text.push_str(if cfg.windows_search_fallback_filesystem {
470        "true"
471    } else {
472        "false"
473    });
474    text.push_str(",\n\n");
475    text.push_str("  // Toggle file and folder visibility in results.\n");
476    text.push_str("  \"show_files\": ");
477    text.push_str(if cfg.show_files { "true" } else { "false" });
478    text.push_str(",\n");
479    text.push_str("  \"show_folders\": ");
480    text.push_str(if cfg.show_folders { "true" } else { "false" });
481    text.push_str(",\n\n");
482
483    text.push_str("  // Search mode default: all | apps | files | actions | clipboard\n");
484    text.push_str("  \"search_mode_default\": ");
485    text.push_str(&json_string(match cfg.search_mode_default {
486        SearchMode::All => "all",
487        SearchMode::Apps => "apps",
488        SearchMode::Files => "files",
489        SearchMode::Actions => "actions",
490        SearchMode::Clipboard => "clipboard",
491    }));
492    text.push_str(",\n");
493    text.push_str(
494        "  // Enable query operators like kind:, modified:, created:, AND/OR/NOT and -term\n",
495    );
496    text.push_str("  \"search_dsl_enabled\": ");
497    text.push_str(if cfg.search_dsl_enabled {
498        "true"
499    } else {
500        "false"
501    });
502    text.push_str(",\n");
503    text.push_str("  // Delay query execution while typing for smoother UI updates\n");
504    text.push_str("  \"search_query_results_with_delay\": ");
505    text.push_str(if cfg.search_query_results_with_delay {
506        "true"
507    } else {
508        "false"
509    });
510    text.push_str(",\n");
511    text.push_str("  // Typing delay in milliseconds (valid range: 10..2000)\n");
512    text.push_str("  \"search_delay_time_ms\": ");
513    text.push_str(&cfg.search_delay_time_ms.to_string());
514    text.push_str(",\n");
515    text.push_str("  // Enable command mode uninstall actions (e.g. > uninstall appname)\n");
516    text.push_str("  \"uninstall_actions_enabled\": ");
517    text.push_str(if cfg.uninstall_actions_enabled {
518        "true"
519    } else {
520        "false"
521    });
522    text.push_str(",\n\n");
523    text.push_str("  // Web search in command mode (press > then type your query)\n");
524    text.push_str("  // Default is google for most users.\n");
525    text.push_str(
526        "  // Options: google | duckduckgo | bing | brave | startpage | ecosia | yahoo | custom\n",
527    );
528    text.push_str("  \"web_search_provider\": ");
529    text.push_str(&json_string(match cfg.web_search_provider {
530        WebSearchProvider::Duckduckgo => "duckduckgo",
531        WebSearchProvider::Google => "google",
532        WebSearchProvider::Bing => "bing",
533        WebSearchProvider::Brave => "brave",
534        WebSearchProvider::Startpage => "startpage",
535        WebSearchProvider::Ecosia => "ecosia",
536        WebSearchProvider::Yahoo => "yahoo",
537        WebSearchProvider::Custom => "custom",
538    }));
539    text.push_str(",\n");
540    text.push_str("  // Used only when provider is custom. Must include {query}.\n");
541    text.push_str("  // Example: \"https://example.com/search?q={query}\"\n");
542    text.push_str("  \"web_search_custom_template\": ");
543    text.push_str(&json_string(&cfg.web_search_custom_template));
544    text.push_str(",\n\n");
545
546    text.push_str("  // Clipboard history provider settings\n");
547    text.push_str("  \"clipboard_enabled\": ");
548    text.push_str(if cfg.clipboard_enabled {
549        "true"
550    } else {
551        "false"
552    });
553    text.push_str(",\n");
554    text.push_str("  // Retention in minutes (valid range: 5..43200)\n");
555    text.push_str("  \"clipboard_retention_minutes\": ");
556    text.push_str(&cfg.clipboard_retention_minutes.to_string());
557    text.push_str(",\n");
558    text.push_str(
559        "  // Substring patterns that should be skipped when capturing clipboard entries\n",
560    );
561    text.push_str("  \"clipboard_exclude_sensitive_patterns\": [\n");
562    for (idx, pattern) in cfg.clipboard_exclude_sensitive_patterns.iter().enumerate() {
563        text.push_str("    ");
564        text.push_str(&json_string(pattern));
565        if idx + 1 != cfg.clipboard_exclude_sensitive_patterns.len() {
566            text.push(',');
567        }
568        text.push('\n');
569    }
570    text.push_str("  ],\n\n");
571
572    text.push_str("  // Plugin SDK settings\n");
573    text.push_str("  \"plugins_enabled\": ");
574    text.push_str(if cfg.plugins_enabled { "true" } else { "false" });
575    text.push_str(",\n");
576    text.push_str("  // Keep safe mode true to prevent plugin command execution.\n");
577    text.push_str("  \"plugins_safe_mode\": ");
578    text.push_str(if cfg.plugins_safe_mode {
579        "true"
580    } else {
581        "false"
582    });
583    text.push_str(",\n");
584    text.push_str("  // Game Mode: suppress the launcher hotkey while a likely game/fullscreen app is active.\n");
585    text.push_str("  \"game_mode_enabled\": ");
586    text.push_str(if cfg.game_mode_enabled {
587        "true"
588    } else {
589        "false"
590    });
591    text.push_str(",\n");
592    text.push_str("  \"plugin_paths\": [\n");
593    for (idx, path) in cfg.plugin_paths.iter().enumerate() {
594        text.push_str("    ");
595        text.push_str(&json_string(&path.to_string_lossy()));
596        if idx + 1 != cfg.plugin_paths.len() {
597            text.push(',');
598        }
599        text.push('\n');
600    }
601    text.push_str("  ],\n\n");
602
603    text.push_str("  // Runtime performance targets\n");
604    text.push_str("  // cache trim after hide in milliseconds (valid range: 100..10000)\n");
605    text.push_str("  \"idle_cache_trim_ms\": ");
606    text.push_str(&cfg.idle_cache_trim_ms.to_string());
607    text.push_str(",\n");
608    text.push_str("  // active memory target in MB (valid range: 20..512)\n");
609    text.push_str("  \"active_memory_target_mb\": ");
610    text.push_str(&cfg.active_memory_target_mb.to_string());
611    text.push_str(",\n");
612    text.push_str("  // Maximum indexed file/folder items retained in database discovery pass\n");
613    text.push_str("  \"index_max_items_total\": ");
614    text.push_str(&cfg.index_max_items_total.to_string());
615    text.push_str(",\n");
616    text.push_str("  // Maximum indexed file/folder items retained per discovery root\n");
617    text.push_str("  \"index_max_items_per_root\": ");
618    text.push_str(&cfg.index_max_items_per_root.to_string());
619    text.push_str(",\n");
620    text.push_str("  // Runtime candidate budget for per-query file/folder retrieval\n");
621    text.push_str("  \"index_max_items_per_query_seed\": ");
622    text.push_str(&cfg.index_max_items_per_query_seed.to_string());
623    text.push('\n');
624    text.push_str("}\n");
625
626    std::fs::write(path, text)?;
627    Ok(())
628}
629
630fn write_user_template_toml(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
631    let roots_section = toml_path_array_section(&cfg.discovery_roots);
632    let excluded_roots_section = toml_path_array_section(&cfg.discovery_exclude_roots);
633    let plugin_paths_section = toml_path_array_section(&cfg.plugin_paths);
634    let clipboard_patterns_section =
635        toml_string_array_section(&cfg.clipboard_exclude_sensitive_patterns);
636
637    let mut text = String::new();
638    text.push_str("# Nex config (TOML format).\n");
639    text.push_str("#\n");
640    text.push_str("# How to use this file:\n");
641    text.push_str("# - Edit values and save.\n");
642    text.push_str("# - Most settings apply automatically within about 1 second.\n");
643    text.push_str("# - Restart required after changing hotkey or index_db_path.\n");
644    text.push_str("# - Strings must be in quotes (example: hotkey = \"Ctrl+Space\").\n");
645    text.push_str("# - Use double backslashes for Windows paths (C:\\\\Users\\\\Admin\\\\...).\n");
646    text.push_str("# - true/false and numbers are not quoted.\n");
647    text.push_str("# - This is the active config. Legacy config.json is kept only as backup.\n");
648    text.push_str("#\n");
649    text.push_str("# Quick setup:\n");
650    text.push_str("# 1) Keep exactly ONE hotkey value.\n");
651    text.push_str("# 2) Save file.\n");
652    text.push_str("# 3) Restart only if you changed hotkey/index_db_path.\n");
653    text.push_str("#\n");
654    text.push_str("# Safer Windows-friendly hotkeys you can use:\n");
655    for option in &cfg.hotkey_recommended {
656        text.push_str("# hotkey = ");
657        text.push_str(&json_string(option));
658        text.push('\n');
659    }
660    text.push_str("# Avoid common OS-reserved shortcuts like Win+..., Alt+Tab, Ctrl+Esc.\n");
661    text.push_str("hotkey = ");
662    text.push_str(&json_string(&cfg.hotkey));
663    text.push_str("\n\n");
664
665    text.push_str("# Start Nex automatically when you sign in (true/false)\n");
666    text.push_str("launch_at_startup = ");
667    text.push_str(if cfg.launch_at_startup {
668        "true"
669    } else {
670        "false"
671    });
672    text.push_str("\n\n");
673
674    text.push_str("# Number of results shown per query (valid range: 5..100)\n");
675    text.push_str("max_results = ");
676    text.push_str(&cfg.max_results.to_string());
677    text.push_str("\n\n");
678
679    text.push_str("# Folders scanned for local files.\n");
680    text.push_str("discovery_roots = ");
681    text.push_str(&roots_section);
682    text.push_str("\n\n");
683    text.push_str("# Folders excluded from local-file discovery.\n");
684    text.push_str("# Nex also skips common system/temp/dev-noise paths automatically\n");
685    text.push_str("# (for example: Windows, Program Files, AppData, node_modules, .git, __pycache__).\n");
686    text.push_str("# Built-in exclusions affect file/folder indexing only, not app discovery.\n");
687    text.push_str("discovery_exclude_roots = ");
688    text.push_str(&excluded_roots_section);
689    text.push_str("\n\n");
690
691    text.push_str("# Use Windows Search index for file/folder discovery when available.\n");
692    text.push_str("windows_search_enabled = ");
693    text.push_str(if cfg.windows_search_enabled {
694        "true"
695    } else {
696        "false"
697    });
698    text.push('\n');
699    text.push_str("# Fall back to direct filesystem scan when Windows Search is unavailable.\n");
700    text.push_str("windows_search_fallback_filesystem = ");
701    text.push_str(if cfg.windows_search_fallback_filesystem {
702        "true"
703    } else {
704        "false"
705    });
706    text.push_str("\n\n");
707
708    text.push_str("# Toggle file and folder visibility in results.\n");
709    text.push_str("show_files = ");
710    text.push_str(if cfg.show_files { "true" } else { "false" });
711    text.push('\n');
712    text.push_str("show_folders = ");
713    text.push_str(if cfg.show_folders { "true" } else { "false" });
714    text.push_str("\n\n");
715
716    text.push_str("# Search mode default: all | apps | files | actions | clipboard\n");
717    text.push_str("search_mode_default = ");
718    text.push_str(&json_string(match cfg.search_mode_default {
719        SearchMode::All => "all",
720        SearchMode::Apps => "apps",
721        SearchMode::Files => "files",
722        SearchMode::Actions => "actions",
723        SearchMode::Clipboard => "clipboard",
724    }));
725    text.push('\n');
726    text.push_str(
727        "# Enable query operators like kind:, modified:, created:, AND/OR/NOT and -term\n",
728    );
729    text.push_str("search_dsl_enabled = ");
730    text.push_str(if cfg.search_dsl_enabled {
731        "true"
732    } else {
733        "false"
734    });
735    text.push('\n');
736    text.push_str("# Delay query execution while typing for smoother UI updates\n");
737    text.push_str("search_query_results_with_delay = ");
738    text.push_str(if cfg.search_query_results_with_delay {
739        "true"
740    } else {
741        "false"
742    });
743    text.push('\n');
744    text.push_str("# Typing delay in milliseconds (valid range: 10..2000)\n");
745    text.push_str("search_delay_time_ms = ");
746    text.push_str(&cfg.search_delay_time_ms.to_string());
747    text.push('\n');
748    text.push_str("# Enable command mode uninstall actions (e.g. > uninstall appname)\n");
749    text.push_str("uninstall_actions_enabled = ");
750    text.push_str(if cfg.uninstall_actions_enabled {
751        "true"
752    } else {
753        "false"
754    });
755    text.push_str("\n\n");
756
757    text.push_str("# Web search in command mode (press > then type your query)\n");
758    text.push_str("# Default is google for most users.\n");
759    text.push_str(
760        "# Options: google | duckduckgo | bing | brave | startpage | ecosia | yahoo | custom\n",
761    );
762    text.push_str("web_search_provider = ");
763    text.push_str(&json_string(match cfg.web_search_provider {
764        WebSearchProvider::Duckduckgo => "duckduckgo",
765        WebSearchProvider::Google => "google",
766        WebSearchProvider::Bing => "bing",
767        WebSearchProvider::Brave => "brave",
768        WebSearchProvider::Startpage => "startpage",
769        WebSearchProvider::Ecosia => "ecosia",
770        WebSearchProvider::Yahoo => "yahoo",
771        WebSearchProvider::Custom => "custom",
772    }));
773    text.push('\n');
774    text.push_str("# Used only when provider is custom. Must include {query}.\n");
775    text.push_str("# Example: \"https://example.com/search?q={query}\"\n");
776    text.push_str("web_search_custom_template = ");
777    text.push_str(&json_string(&cfg.web_search_custom_template));
778    text.push_str("\n\n");
779
780    text.push_str("# Clipboard history provider settings\n");
781    text.push_str("clipboard_enabled = ");
782    text.push_str(if cfg.clipboard_enabled {
783        "true"
784    } else {
785        "false"
786    });
787    text.push('\n');
788    text.push_str("# Retention in minutes (valid range: 5..43200)\n");
789    text.push_str("clipboard_retention_minutes = ");
790    text.push_str(&cfg.clipboard_retention_minutes.to_string());
791    text.push('\n');
792    text.push_str("# Substring patterns skipped when capturing clipboard entries\n");
793    text.push_str("clipboard_exclude_sensitive_patterns = ");
794    text.push_str(&clipboard_patterns_section);
795    text.push_str("\n\n");
796
797    text.push_str("# Plugin SDK settings\n");
798    text.push_str("plugins_enabled = ");
799    text.push_str(if cfg.plugins_enabled { "true" } else { "false" });
800    text.push('\n');
801    text.push_str("# Keep safe mode true to prevent plugin command execution.\n");
802    text.push_str("plugins_safe_mode = ");
803    text.push_str(if cfg.plugins_safe_mode {
804        "true"
805    } else {
806        "false"
807    });
808    text.push('\n');
809    text.push_str("# Game Mode: suppress the launcher hotkey while a likely game/fullscreen app is active.\n");
810    text.push_str("game_mode_enabled = ");
811    text.push_str(if cfg.game_mode_enabled {
812        "true"
813    } else {
814        "false"
815    });
816    text.push('\n');
817    text.push_str("plugin_paths = ");
818    text.push_str(&plugin_paths_section);
819    text.push_str("\n\n");
820
821    text.push_str("# Runtime performance targets\n");
822    text.push_str("# cache trim after hide in milliseconds (valid range: 100..10000)\n");
823    text.push_str("idle_cache_trim_ms = ");
824    text.push_str(&cfg.idle_cache_trim_ms.to_string());
825    text.push('\n');
826    text.push_str("# active memory target in MB (valid range: 20..512)\n");
827    text.push_str("active_memory_target_mb = ");
828    text.push_str(&cfg.active_memory_target_mb.to_string());
829    text.push('\n');
830    text.push_str("# Maximum indexed file/folder items retained in discovery pass\n");
831    text.push_str("index_max_items_total = ");
832    text.push_str(&cfg.index_max_items_total.to_string());
833    text.push('\n');
834    text.push_str("# Maximum indexed file/folder items retained per discovery root\n");
835    text.push_str("index_max_items_per_root = ");
836    text.push_str(&cfg.index_max_items_per_root.to_string());
837    text.push('\n');
838    text.push_str("# Runtime candidate budget for per-query file/folder retrieval\n");
839    text.push_str("index_max_items_per_query_seed = ");
840    text.push_str(&cfg.index_max_items_per_query_seed.to_string());
841    text.push('\n');
842
843    std::fs::write(path, text)?;
844    Ok(())
845}
846
847pub fn validate(cfg: &Config) -> Result<(), String> {
848    if cfg.max_results < 5 || cfg.max_results > 100 {
849        return Err("max_results out of range".into());
850    }
851
852    if cfg.index_db_path.as_os_str().is_empty() {
853        return Err("index_db_path is required".into());
854    }
855
856    if cfg.config_path.as_os_str().is_empty() {
857        return Err("config_path is required".into());
858    }
859
860    if cfg.hotkey.trim().is_empty() {
861        return Err("hotkey is required".into());
862    }
863
864    if cfg.clipboard_retention_minutes < 5 || cfg.clipboard_retention_minutes > 43_200 {
865        return Err("clipboard_retention_minutes out of range".into());
866    }
867
868    if cfg.idle_cache_trim_ms < 100 || cfg.idle_cache_trim_ms > 10_000 {
869        return Err("idle_cache_trim_ms out of range".into());
870    }
871
872    if cfg.active_memory_target_mb < 20 || cfg.active_memory_target_mb > 512 {
873        return Err("active_memory_target_mb out of range".into());
874    }
875    if cfg.search_delay_time_ms < 10 || cfg.search_delay_time_ms > 2_000 {
876        return Err("search_delay_time_ms out of range".into());
877    }
878
879    if cfg.index_max_items_total < 10_000 || cfg.index_max_items_total > 2_000_000 {
880        return Err("index_max_items_total out of range".into());
881    }
882
883    if cfg.index_max_items_per_root < 1_000 || cfg.index_max_items_per_root > 1_000_000 {
884        return Err("index_max_items_per_root out of range".into());
885    }
886
887    if cfg.index_max_items_per_query_seed < 250 || cfg.index_max_items_per_query_seed > 200_000 {
888        return Err("index_max_items_per_query_seed out of range".into());
889    }
890
891    if cfg.index_max_items_per_root > cfg.index_max_items_total {
892        return Err("index_max_items_per_root must be <= index_max_items_total".into());
893    }
894
895    if cfg.web_search_provider == WebSearchProvider::Custom {
896        let template = cfg.web_search_custom_template.trim();
897        if template.is_empty() {
898            return Err(
899                "web_search_custom_template is required when web_search_provider=custom".into(),
900            );
901        }
902        if !template.contains("{query}") {
903            return Err("web_search_custom_template must include {query} placeholder".into());
904        }
905    }
906
907    if cfg
908        .discovery_roots
909        .iter()
910        .any(|root| root.as_os_str().is_empty())
911    {
912        return Err("discovery_roots contains an empty path".into());
913    }
914
915    if cfg
916        .discovery_exclude_roots
917        .iter()
918        .any(|root| root.as_os_str().is_empty())
919    {
920        return Err("discovery_exclude_roots contains an empty path".into());
921    }
922
923    if cfg
924        .plugin_paths
925        .iter()
926        .any(|path| path.as_os_str().is_empty())
927    {
928        return Err("plugin_paths contains an empty path".into());
929    }
930
931    if cfg
932        .clipboard_exclude_sensitive_patterns
933        .iter()
934        .any(|pattern| pattern.trim().is_empty())
935    {
936        return Err("clipboard_exclude_sensitive_patterns contains an empty pattern".into());
937    }
938
939    crate::settings::validate_hotkey(&cfg.hotkey)
940        .map_err(|error| format!("hotkey is invalid: {error}"))?;
941
942    if cfg.version == 0 {
943        return Err("version must be >= 1".into());
944    }
945
946    Ok(())
947}
948
949fn write_atomic(path: &Path, encoded: &str) -> Result<(), ConfigError> {
950    let parent = path.parent().unwrap_or_else(|| Path::new("."));
951    let ts = SystemTime::now()
952        .duration_since(UNIX_EPOCH)
953        .map(|d| d.as_nanos())
954        .unwrap_or(0);
955    let temp_path = parent.join(format!(".nex-config-{ts}.tmp"));
956    let backup_path = parent.join(".nex-config.backup");
957
958    std::fs::write(&temp_path, encoded)?;
959
960    if backup_path.exists() {
961        let _ = std::fs::remove_file(&backup_path);
962    }
963    if path.exists() {
964        std::fs::rename(path, &backup_path)?;
965    }
966
967    match std::fs::rename(&temp_path, path) {
968        Ok(()) => {
969            if backup_path.exists() {
970                let _ = std::fs::remove_file(&backup_path);
971            }
972            Ok(())
973        }
974        Err(error) => {
975            if backup_path.exists() {
976                let _ = std::fs::rename(&backup_path, path);
977            }
978            let _ = std::fs::remove_file(&temp_path);
979            Err(ConfigError::Io(error))
980        }
981    }
982}
983
984fn json5_path_array_section(paths: &[PathBuf]) -> String {
985    let body = paths
986        .iter()
987        .map(|path| format!("    {}", json_string(&path.to_string_lossy())))
988        .collect::<Vec<_>>()
989        .join(",\n");
990
991    if body.is_empty() {
992        "[]".to_string()
993    } else {
994        format!("[\n{body}\n  ]")
995    }
996}
997
998fn toml_path_array_section(paths: &[PathBuf]) -> String {
999    let values = paths
1000        .iter()
1001        .map(|path| path.to_string_lossy().to_string())
1002        .collect::<Vec<_>>();
1003    toml_string_array_section(&values)
1004}
1005
1006fn toml_string_array_section(values: &[String]) -> String {
1007    if values.is_empty() {
1008        return "[]".to_string();
1009    }
1010
1011    let body = values
1012        .iter()
1013        .map(|value| format!("  {}", json_string(value)))
1014        .collect::<Vec<_>>()
1015        .join(",\n");
1016    format!("[\n{body},\n]")
1017}
1018
1019fn default_discovery_roots() -> Vec<PathBuf> {
1020    #[cfg(target_os = "windows")]
1021    {
1022        if let Some(profile_root) = windows_user_profile_root() {
1023            return vec![profile_root];
1024        }
1025    }
1026
1027    Vec::new()
1028}
1029
1030fn default_discovery_exclude_roots() -> Vec<PathBuf> {
1031    #[cfg(target_os = "windows")]
1032    {
1033        if let Some(profile_root) = windows_user_profile_root() {
1034            return vec![
1035                profile_root.join("AppData").join("Local").join("Temp"),
1036                profile_root
1037                    .join("AppData")
1038                    .join("Local")
1039                    .join("Microsoft")
1040                    .join("Windows")
1041                    .join("INetCache"),
1042            ];
1043        }
1044    }
1045
1046    Vec::new()
1047}
1048
1049#[cfg(target_os = "windows")]
1050fn windows_user_profile_root() -> Option<PathBuf> {
1051    if let Ok(user_profile) = std::env::var("USERPROFILE") {
1052        let trimmed = user_profile.trim();
1053        if !trimmed.is_empty() {
1054            return Some(PathBuf::from(trimmed));
1055        }
1056    }
1057
1058    let home_drive = std::env::var("HOMEDRIVE").ok();
1059    let home_path = std::env::var("HOMEPATH").ok();
1060    if let (Some(drive), Some(path)) = (home_drive, home_path) {
1061        let combined = format!("{}{}", drive.trim(), path.trim());
1062        let trimmed = combined.trim();
1063        if !trimmed.is_empty() {
1064            return Some(PathBuf::from(trimmed));
1065        }
1066    }
1067
1068    None
1069}
1070
1071fn default_for_path(path: &Path) -> Config {
1072    let mut cfg = Config::default();
1073    cfg.config_path = path.to_path_buf();
1074    if cfg.index_db_path == Config::default().index_db_path {
1075        cfg.index_db_path = path
1076            .parent()
1077            .unwrap_or_else(|| Path::new("."))
1078            .join("index.sqlite3");
1079    }
1080    cfg
1081}
1082
1083fn apply_migrations(cfg: &mut Config, raw: &str) -> bool {
1084    let mut changed = false;
1085    let source_version = cfg.version.max(1);
1086
1087    if cfg.version < CURRENT_CONFIG_VERSION {
1088        cfg.version = CURRENT_CONFIG_VERSION;
1089        changed = true;
1090    }
1091
1092    if source_version < 2 {
1093        let had_idle_key = raw_has_key(raw, "idle_cache_trim_ms");
1094        let had_active_mem_key = raw_has_key(raw, "active_memory_target_mb");
1095        if !had_idle_key || cfg.idle_cache_trim_ms == LEGACY_IDLE_CACHE_TRIM_MS_V1 {
1096            cfg.idle_cache_trim_ms = Config::default().idle_cache_trim_ms;
1097            changed = true;
1098        }
1099        if !had_active_mem_key || cfg.active_memory_target_mb == LEGACY_ACTIVE_MEMORY_TARGET_MB_V1 {
1100            cfg.active_memory_target_mb = Config::default().active_memory_target_mb;
1101            changed = true;
1102        }
1103    }
1104
1105    if source_version < 3 {
1106        if !raw_has_key(raw, "web_search_provider") {
1107            cfg.web_search_provider = Config::default().web_search_provider;
1108            changed = true;
1109        }
1110        if !raw_has_key(raw, "web_search_custom_template") {
1111            cfg.web_search_custom_template = Config::default().web_search_custom_template;
1112            changed = true;
1113        }
1114    }
1115
1116    if source_version < 4 {
1117        if !raw_has_key(raw, "windows_search_enabled") {
1118            cfg.windows_search_enabled = Config::default().windows_search_enabled;
1119            changed = true;
1120        }
1121        if !raw_has_key(raw, "windows_search_fallback_filesystem") {
1122            cfg.windows_search_fallback_filesystem =
1123                Config::default().windows_search_fallback_filesystem;
1124            changed = true;
1125        }
1126    }
1127
1128    if source_version < 5 {
1129        if !raw_has_key(raw, "show_files") {
1130            cfg.show_files = Config::default().show_files;
1131            changed = true;
1132        }
1133        if !raw_has_key(raw, "show_folders") {
1134            cfg.show_folders = Config::default().show_folders;
1135            changed = true;
1136        }
1137    }
1138
1139    if source_version < 6 && !raw_has_key(raw, "uninstall_actions_enabled") {
1140        cfg.uninstall_actions_enabled = Config::default().uninstall_actions_enabled;
1141        changed = true;
1142    }
1143
1144    if source_version < 7 {
1145        if !raw_has_key(raw, "index_max_items_total") {
1146            cfg.index_max_items_total = Config::default().index_max_items_total;
1147            changed = true;
1148        }
1149        if !raw_has_key(raw, "index_max_items_per_root") {
1150            cfg.index_max_items_per_root = Config::default().index_max_items_per_root;
1151            changed = true;
1152        }
1153        if !raw_has_key(raw, "index_max_items_per_query_seed") {
1154            cfg.index_max_items_per_query_seed = Config::default().index_max_items_per_query_seed;
1155            changed = true;
1156        }
1157    }
1158
1159    if source_version < 10 && !raw_has_key(raw, "game_mode_enabled") {
1160        cfg.game_mode_enabled = Config::default().game_mode_enabled;
1161        changed = true;
1162    }
1163
1164    if source_version < 9 {
1165        if !raw_has_key(raw, "search_query_results_with_delay") {
1166            cfg.search_query_results_with_delay = Config::default().search_query_results_with_delay;
1167            changed = true;
1168        }
1169        if !raw_has_key(raw, "search_delay_time_ms") {
1170            cfg.search_delay_time_ms = Config::default().search_delay_time_ms;
1171            changed = true;
1172        }
1173    }
1174
1175    if TEMPLATE_REQUIRED_KEYS
1176        .iter()
1177        .any(|key| !raw_has_key(raw, key))
1178    {
1179        changed = true;
1180    }
1181
1182    if raw.contains(LEGACY_APP_DISPLAY_NAME) || raw.contains(LEGACY_APP_DIR_NAME_UNIX) {
1183        changed = true;
1184    }
1185
1186    changed
1187}
1188
1189fn persist_migrated_config(
1190    cfg: &Config,
1191    path: &Path,
1192    original_raw: &str,
1193    source_version: u32,
1194    source_path: &Path,
1195) -> Result<(), ConfigError> {
1196    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1197    std::fs::create_dir_all(parent)?;
1198
1199    let stamp = SystemTime::now()
1200        .duration_since(UNIX_EPOCH)
1201        .map(|d| d.as_secs())
1202        .unwrap_or(0);
1203    let backup_ext = source_path
1204        .extension()
1205        .and_then(|ext| ext.to_str())
1206        .filter(|ext| !ext.trim().is_empty())
1207        .unwrap_or("txt");
1208    let backup_path = parent.join(format!(
1209        "config.v{}-backup-{}.{}",
1210        source_version.max(1),
1211        stamp,
1212        backup_ext
1213    ));
1214    std::fs::write(&backup_path, original_raw)?;
1215    write_user_template(cfg, path)
1216}
1217
1218fn raw_has_key(raw: &str, key: &str) -> bool {
1219    let quoted = format!("\"{key}\"");
1220    if raw.contains(&quoted) {
1221        return true;
1222    }
1223    let toml_key = format!("{key} =");
1224    if raw.contains(&toml_key) {
1225        return true;
1226    }
1227    let bare = format!("{key}:");
1228    raw.contains(&bare)
1229}
1230
1231fn parse_text(raw: &str) -> Result<Config, ConfigError> {
1232    match toml::from_str::<Config>(raw) {
1233        Ok(cfg) => Ok(cfg),
1234        Err(toml_err) => match serde_json::from_str::<Config>(raw) {
1235            Ok(cfg) => Ok(cfg),
1236            Err(json_err) => match json5::from_str::<Config>(raw) {
1237                Ok(cfg) => Ok(cfg),
1238                Err(json5_err) => Err(ConfigError::Parse(format!(
1239                    "invalid config format. toml error: {toml_err}; json error: {json_err}; json5 error: {json5_err}"
1240                ))),
1241            },
1242        },
1243    }
1244}
1245
1246fn json_string(value: &str) -> String {
1247    serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
1248}
1249
1250#[cfg(target_os = "windows")]
1251fn windows_app_data_dir(app_dir_name: &str) -> Option<PathBuf> {
1252    if let Ok(app_data) = std::env::var("APPDATA") {
1253        return Some(PathBuf::from(app_data).join(app_dir_name));
1254    }
1255
1256    if let Ok(user_profile) = std::env::var("USERPROFILE") {
1257        return Some(
1258            PathBuf::from(user_profile)
1259                .join("AppData")
1260                .join("Roaming")
1261                .join(app_dir_name),
1262        );
1263    }
1264
1265    None
1266}
1267
1268#[cfg(not(target_os = "windows"))]
1269fn unix_app_data_dir(app_dir_name: &str) -> Option<PathBuf> {
1270    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
1271        return Some(PathBuf::from(xdg).join(app_dir_name));
1272    }
1273
1274    if let Ok(home) = std::env::var("HOME") {
1275        return Some(PathBuf::from(home).join(".config").join(app_dir_name));
1276    }
1277
1278    None
1279}
1280
1281fn migrate_legacy_app_data_dir(preferred: PathBuf, legacy: Option<PathBuf>) -> PathBuf {
1282    let Some(legacy) = legacy else {
1283        return preferred;
1284    };
1285
1286    if legacy == preferred || !legacy.exists() {
1287        return preferred;
1288    }
1289
1290    if !preferred.exists() {
1291        if let Some(parent) = preferred.parent() {
1292            let _ = std::fs::create_dir_all(parent);
1293        }
1294        return match std::fs::rename(&legacy, &preferred) {
1295            Ok(()) => preferred,
1296            Err(_) => legacy,
1297        };
1298    }
1299
1300    match move_missing_entries(&legacy, &preferred) {
1301        Ok(()) => preferred,
1302        Err(_) => {
1303            if app_data_dir_has_state(&preferred) || !app_data_dir_has_state(&legacy) {
1304                preferred
1305            } else {
1306                legacy
1307            }
1308        }
1309    }
1310}
1311
1312fn move_missing_entries(from: &Path, to: &Path) -> std::io::Result<()> {
1313    std::fs::create_dir_all(to)?;
1314    for entry in std::fs::read_dir(from)? {
1315        let entry = entry?;
1316        let target = to.join(entry.file_name());
1317        if target.exists() {
1318            continue;
1319        }
1320        std::fs::rename(entry.path(), target)?;
1321    }
1322
1323    let is_empty = std::fs::read_dir(from)?.next().transpose()?.is_none();
1324    if is_empty {
1325        let _ = std::fs::remove_dir(from);
1326    }
1327    Ok(())
1328}
1329
1330fn app_data_dir_has_state(path: &Path) -> bool {
1331    [CONFIG_FILE_NAME, LEGACY_CONFIG_FILE_NAME, "index.sqlite3"]
1332        .iter()
1333        .any(|file_name| path.join(file_name).exists())
1334}
1335
1336fn rewrite_managed_paths_to_current_app_dir(cfg: &mut Config) -> bool {
1337    let current_dir = stable_app_data_dir();
1338
1339    #[cfg(target_os = "windows")]
1340    let legacy_dir = windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS);
1341    #[cfg(not(target_os = "windows"))]
1342    let legacy_dir = unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX);
1343
1344    let Some(legacy_dir) = legacy_dir else {
1345        return false;
1346    };
1347    if legacy_dir == current_dir {
1348        return false;
1349    }
1350
1351    let mut changed = false;
1352
1353    if let Some(rebased) = rebase_managed_path(&cfg.index_db_path, &legacy_dir, &current_dir) {
1354        cfg.index_db_path = rebased;
1355        changed = true;
1356    }
1357
1358    for plugin_path in &mut cfg.plugin_paths {
1359        if let Some(rebased) = rebase_managed_path(plugin_path, &legacy_dir, &current_dir) {
1360            *plugin_path = rebased;
1361            changed = true;
1362        }
1363    }
1364
1365    changed
1366}
1367
1368fn rebase_managed_path(path: &Path, from_root: &Path, to_root: &Path) -> Option<PathBuf> {
1369    let relative = path.strip_prefix(from_root).ok()?;
1370    Some(to_root.join(relative))
1371}