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("ed) {
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, ¤t_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, ¤t_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}