use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
pub const APP_DISPLAY_NAME: &str = "Nex";
pub const LEGACY_APP_DISPLAY_NAME: &str = "SwiftFind";
#[cfg(target_os = "windows")]
const APP_DIR_NAME_WINDOWS: &str = "Nex";
#[cfg(target_os = "windows")]
const LEGACY_APP_DIR_NAME_WINDOWS: &str = "SwiftFind";
const APP_DIR_NAME_UNIX: &str = "nex";
const LEGACY_APP_DIR_NAME_UNIX: &str = "swiftfind";
const CONFIG_FILE_NAME: &str = "config.toml";
const LEGACY_CONFIG_FILE_NAME: &str = "config.json";
pub const CURRENT_CONFIG_VERSION: u32 = 11;
const LEGACY_IDLE_CACHE_TRIM_MS_V1: u32 = 1200;
const LEGACY_ACTIVE_MEMORY_TARGET_MB_V1: u16 = 80;
const TEMPLATE_REQUIRED_KEYS: &[&str] = &[
"hotkey",
"launch_at_startup",
"max_results",
"discovery_roots",
"discovery_exclude_roots",
"windows_search_enabled",
"windows_search_fallback_filesystem",
"show_files",
"show_folders",
"search_mode_default",
"search_dsl_enabled",
"search_query_results_with_delay",
"search_delay_time_ms",
"uninstall_actions_enabled",
"web_search_provider",
"web_search_custom_template",
"clipboard_enabled",
"clipboard_retention_minutes",
"clipboard_exclude_sensitive_patterns",
"plugins_enabled",
"plugins_safe_mode",
"game_mode_enabled",
"plugin_paths",
"idle_cache_trim_ms",
"active_memory_target_mb",
"index_max_items_total",
"index_max_items_per_root",
"index_max_items_per_query_seed",
];
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum SearchMode {
#[default]
All,
Apps,
Files,
Actions,
Clipboard,
}
impl SearchMode {
pub fn parse(value: &str) -> Option<Self> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"all" => Some(Self::All),
"apps" | "app" => Some(Self::Apps),
"files" | "file" => Some(Self::Files),
"actions" | "action" => Some(Self::Actions),
"clipboard" | "clip" => Some(Self::Clipboard),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum WebSearchProvider {
Duckduckgo,
#[default]
Google,
Bing,
Brave,
Startpage,
Ecosia,
Yahoo,
Custom,
}
impl WebSearchProvider {
pub fn label(self) -> &'static str {
match self {
Self::Duckduckgo => "DuckDuckGo",
Self::Google => "Google",
Self::Bing => "Bing",
Self::Brave => "Brave",
Self::Startpage => "Startpage",
Self::Ecosia => "Ecosia",
Self::Yahoo => "Yahoo",
Self::Custom => "Custom",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct Config {
pub version: u32,
pub max_results: u16,
pub index_db_path: PathBuf,
pub config_path: PathBuf,
pub discovery_roots: Vec<PathBuf>,
pub discovery_exclude_roots: Vec<PathBuf>,
pub windows_search_enabled: bool,
pub windows_search_fallback_filesystem: bool,
pub show_files: bool,
pub show_folders: bool,
pub hotkey: String,
pub launch_at_startup: bool,
pub hotkey_help: String,
pub hotkey_recommended: Vec<String>,
pub search_mode_default: SearchMode,
pub search_dsl_enabled: bool,
pub search_query_results_with_delay: bool,
pub search_delay_time_ms: u16,
pub uninstall_actions_enabled: bool,
pub web_search_provider: WebSearchProvider,
pub web_search_custom_template: String,
pub clipboard_enabled: bool,
pub clipboard_retention_minutes: u32,
pub clipboard_exclude_sensitive_patterns: Vec<String>,
pub plugins_enabled: bool,
pub plugin_paths: Vec<PathBuf>,
pub plugins_safe_mode: bool,
pub game_mode_enabled: bool,
pub idle_cache_trim_ms: u32,
pub active_memory_target_mb: u16,
pub index_max_items_total: u32,
pub index_max_items_per_root: u32,
pub index_max_items_per_query_seed: u32,
}
impl Default for Config {
fn default() -> Self {
let app_dir = stable_app_data_dir();
let config_path = app_dir.join(CONFIG_FILE_NAME);
Self {
version: CURRENT_CONFIG_VERSION,
max_results: 20,
index_db_path: app_dir.join("index.sqlite3"),
config_path,
discovery_roots: default_discovery_roots(),
discovery_exclude_roots: default_discovery_exclude_roots(),
windows_search_enabled: true,
windows_search_fallback_filesystem: true,
show_files: false,
show_folders: false,
hotkey: "Ctrl+Space".to_string(),
launch_at_startup: false,
hotkey_help: format!(
"Set `hotkey` as Modifier+Key (example: Ctrl+Space), then restart {APP_DISPLAY_NAME}."
),
hotkey_recommended: vec![
"Ctrl+Space".to_string(),
"Ctrl+Shift+Space".to_string(),
"Ctrl+Alt+Space".to_string(),
"Alt+Shift+Space".to_string(),
"Ctrl+Shift+P".to_string(),
"Ctrl+Alt+P".to_string(),
],
search_mode_default: SearchMode::All,
search_dsl_enabled: true,
search_query_results_with_delay: true,
search_delay_time_ms: 90,
uninstall_actions_enabled: true,
web_search_provider: WebSearchProvider::Google,
web_search_custom_template: String::new(),
clipboard_enabled: true,
clipboard_retention_minutes: 8 * 60,
clipboard_exclude_sensitive_patterns: vec![
"password".to_string(),
"passcode".to_string(),
"otp".to_string(),
"token".to_string(),
"secret".to_string(),
"apikey".to_string(),
"api_key".to_string(),
],
plugins_enabled: true,
plugin_paths: vec![app_dir.join("plugins")],
plugins_safe_mode: true,
game_mode_enabled: false,
idle_cache_trim_ms: 900,
active_memory_target_mb: 72,
index_max_items_total: 120_000,
index_max_items_per_root: 40_000,
index_max_items_per_query_seed: 5_000,
}
}
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Parse(String),
Validation(String),
}
impl Display for ConfigError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "io error: {error}"),
Self::Parse(error) => write!(f, "parse error: {error}"),
Self::Validation(error) => write!(f, "validation error: {error}"),
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<serde_json::Error> for ConfigError {
fn from(value: serde_json::Error) -> Self {
Self::Parse(value.to_string())
}
}
pub fn stable_app_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
if let Some(preferred) = windows_app_data_dir(APP_DIR_NAME_WINDOWS) {
return migrate_legacy_app_data_dir(
preferred,
windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS),
);
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(preferred) = unix_app_data_dir(APP_DIR_NAME_UNIX) {
return migrate_legacy_app_data_dir(
preferred,
unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX),
);
}
}
std::env::temp_dir().join(APP_DIR_NAME_UNIX)
}
pub fn stable_config_path() -> PathBuf {
stable_app_data_dir().join(CONFIG_FILE_NAME)
}
fn stable_legacy_config_paths() -> Vec<PathBuf> {
let current_dir = stable_app_data_dir();
let mut paths = vec![current_dir.join(LEGACY_CONFIG_FILE_NAME)];
#[cfg(target_os = "windows")]
{
if let Some(legacy_dir) = windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS) {
if legacy_dir != current_dir {
paths.push(legacy_dir.join(LEGACY_CONFIG_FILE_NAME));
}
}
}
#[cfg(not(target_os = "windows"))]
{
if let Some(legacy_dir) = unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX) {
if legacy_dir != current_dir {
paths.push(legacy_dir.join(LEGACY_CONFIG_FILE_NAME));
}
}
}
paths.sort();
paths.dedup();
paths
}
fn is_toml_path(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("toml"))
.unwrap_or(false)
}
pub fn load(path: Option<&Path>) -> Result<Config, ConfigError> {
let resolved_path = path
.map(Path::to_path_buf)
.unwrap_or_else(stable_config_path);
if !resolved_path.exists() {
if path.is_none() {
if let Some(legacy_path) = stable_legacy_config_paths()
.into_iter()
.find(|candidate| candidate.exists())
{
let raw = std::fs::read_to_string(&legacy_path)?;
let mut cfg: Config = parse_text(&raw)?;
let source_version = cfg.version;
cfg.config_path = resolved_path.clone();
if cfg.index_db_path.as_os_str().is_empty() {
cfg.index_db_path = resolved_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("index.sqlite3");
}
let mut should_persist_migration = apply_migrations(&mut cfg, &raw);
should_persist_migration |= rewrite_managed_paths_to_current_app_dir(&mut cfg);
validate(&cfg).map_err(ConfigError::Validation)?;
if should_persist_migration {
persist_migrated_config(
&cfg,
&resolved_path,
&raw,
source_version,
&legacy_path,
)?;
}
return Ok(cfg);
}
}
let cfg = default_for_path(&resolved_path);
validate(&cfg).map_err(ConfigError::Validation)?;
return Ok(cfg);
}
let raw = std::fs::read_to_string(&resolved_path)?;
let mut cfg: Config = parse_text(&raw)?;
let source_version = cfg.version;
cfg.config_path = resolved_path.clone();
if cfg.index_db_path.as_os_str().is_empty() {
cfg.index_db_path = resolved_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("index.sqlite3");
}
let mut should_persist_migration = apply_migrations(&mut cfg, &raw);
should_persist_migration |= rewrite_managed_paths_to_current_app_dir(&mut cfg);
validate(&cfg).map_err(ConfigError::Validation)?;
if should_persist_migration {
persist_migrated_config(&cfg, &resolved_path, &raw, source_version, &resolved_path)?;
}
Ok(cfg)
}
pub fn save(cfg: &Config) -> Result<(), ConfigError> {
save_to_path(cfg, &cfg.config_path)
}
pub fn save_to_path(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
validate(cfg).map_err(ConfigError::Validation)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let encoded = if is_toml_path(path) {
toml::to_string_pretty(cfg)
.map_err(|error| ConfigError::Parse(format!("toml encode error: {error}")))?
} else {
serde_json::to_string_pretty(cfg)?
};
write_atomic(path, &encoded)
}
pub fn write_user_template(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
validate(cfg).map_err(ConfigError::Validation)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if is_toml_path(path) {
return write_user_template_toml(cfg, path);
}
let roots_section = json5_path_array_section(&cfg.discovery_roots);
let excluded_roots_section = json5_path_array_section(&cfg.discovery_exclude_roots);
let mut text = String::new();
text.push_str("{\n");
text.push_str(" // Nex config (comments are allowed).\n");
text.push_str(" //\n");
text.push_str(" // How to use this file:\n");
text.push_str(" // - Edit values and save.\n");
text.push_str(" // - Most settings apply automatically within about 1 second.\n");
text.push_str(" // - Restart required after changing hotkey or index_db_path.\n");
text.push_str(" // - Keep strings in double quotes.\n");
text.push_str(
" // - Use double backslashes for Windows paths (C:\\\\Users\\\\Admin\\\\...).\n",
);
text.push_str(" // - true/false and numbers must not be quoted.\n");
text.push_str(" // - This file is the active config while using .json format.\n");
text.push_str(" //\n");
text.push_str(" // Quick setup:\n");
text.push_str(" // 1) Keep exactly ONE `hotkey` line uncommented.\n");
text.push_str(" // 2) Save file.\n");
text.push_str(" // 3) Restart only if you changed hotkey/index_db_path.\n");
text.push_str(" //\n");
text.push_str(" // Safer Windows-friendly hotkeys (uncomment one if you prefer):\n");
for option in &cfg.hotkey_recommended {
if option != &cfg.hotkey {
text.push_str(" // \"hotkey\": ");
text.push_str(&json_string(option));
text.push_str(",\n");
}
}
text.push_str(
" // Avoid common OS-reserved/conflicting shortcuts like Win+..., Alt+Tab, Ctrl+Esc.\n",
);
text.push_str(" \"hotkey\": ");
text.push_str(&json_string(&cfg.hotkey));
text.push_str(",\n");
text.push_str(" // Start Nex automatically when you sign in (true/false)\n");
text.push_str(" \"launch_at_startup\": ");
text.push_str(if cfg.launch_at_startup {
"true"
} else {
"false"
});
text.push_str(",\n\n");
text.push_str(" // Optional tuning:\n");
text.push_str(" // Number of results shown per query (valid range: 5..100)\n");
text.push_str(" \"max_results\": ");
text.push_str(&cfg.max_results.to_string());
text.push_str(",\n\n");
text.push_str(" // Optional: folders scanned for local files.\n");
text.push_str(" // Add/remove paths as needed.\n");
text.push_str(" \"discovery_roots\": ");
text.push_str(&roots_section);
text.push_str(",\n\n");
text.push_str(" // Optional: folders to exclude from local-file discovery.\n");
text.push_str(" // Any file/folder under these roots is ignored.\n");
text.push_str(" // Nex also skips common system/temp/dev-noise paths automatically\n");
text.push_str(
" // (for example: Windows, Program Files, AppData, node_modules, .git, __pycache__).\n",
);
text.push_str(
" // These built-in exclusions affect file/folder indexing only, not app discovery.\n",
);
text.push_str(" \"discovery_exclude_roots\": ");
text.push_str(&excluded_roots_section);
text.push_str(",\n\n");
text.push_str(" // Use Windows Search index for file/folder discovery when available.\n");
text.push_str(" \"windows_search_enabled\": ");
text.push_str(if cfg.windows_search_enabled {
"true"
} else {
"false"
});
text.push_str(",\n");
text.push_str(" // Fall back to direct filesystem scan when Windows Search is unavailable.\n");
text.push_str(" \"windows_search_fallback_filesystem\": ");
text.push_str(if cfg.windows_search_fallback_filesystem {
"true"
} else {
"false"
});
text.push_str(",\n\n");
text.push_str(" // Toggle file and folder visibility in results.\n");
text.push_str(" \"show_files\": ");
text.push_str(if cfg.show_files { "true" } else { "false" });
text.push_str(",\n");
text.push_str(" \"show_folders\": ");
text.push_str(if cfg.show_folders { "true" } else { "false" });
text.push_str(",\n\n");
text.push_str(" // Search mode default: all | apps | files | actions | clipboard\n");
text.push_str(" \"search_mode_default\": ");
text.push_str(&json_string(match cfg.search_mode_default {
SearchMode::All => "all",
SearchMode::Apps => "apps",
SearchMode::Files => "files",
SearchMode::Actions => "actions",
SearchMode::Clipboard => "clipboard",
}));
text.push_str(",\n");
text.push_str(
" // Enable query operators like kind:, modified:, created:, AND/OR/NOT and -term\n",
);
text.push_str(" \"search_dsl_enabled\": ");
text.push_str(if cfg.search_dsl_enabled {
"true"
} else {
"false"
});
text.push_str(",\n");
text.push_str(" // Delay query execution while typing for smoother UI updates\n");
text.push_str(" \"search_query_results_with_delay\": ");
text.push_str(if cfg.search_query_results_with_delay {
"true"
} else {
"false"
});
text.push_str(",\n");
text.push_str(" // Typing delay in milliseconds (valid range: 10..2000)\n");
text.push_str(" \"search_delay_time_ms\": ");
text.push_str(&cfg.search_delay_time_ms.to_string());
text.push_str(",\n");
text.push_str(" // Enable command mode uninstall actions (e.g. > uninstall appname)\n");
text.push_str(" \"uninstall_actions_enabled\": ");
text.push_str(if cfg.uninstall_actions_enabled {
"true"
} else {
"false"
});
text.push_str(",\n\n");
text.push_str(" // Web search in command mode (press > then type your query)\n");
text.push_str(" // Default is google for most users.\n");
text.push_str(
" // Options: google | duckduckgo | bing | brave | startpage | ecosia | yahoo | custom\n",
);
text.push_str(" \"web_search_provider\": ");
text.push_str(&json_string(match cfg.web_search_provider {
WebSearchProvider::Duckduckgo => "duckduckgo",
WebSearchProvider::Google => "google",
WebSearchProvider::Bing => "bing",
WebSearchProvider::Brave => "brave",
WebSearchProvider::Startpage => "startpage",
WebSearchProvider::Ecosia => "ecosia",
WebSearchProvider::Yahoo => "yahoo",
WebSearchProvider::Custom => "custom",
}));
text.push_str(",\n");
text.push_str(" // Used only when provider is custom. Must include {query}.\n");
text.push_str(" // Example: \"https://example.com/search?q={query}\"\n");
text.push_str(" \"web_search_custom_template\": ");
text.push_str(&json_string(&cfg.web_search_custom_template));
text.push_str(",\n\n");
text.push_str(" // Clipboard history provider settings\n");
text.push_str(" \"clipboard_enabled\": ");
text.push_str(if cfg.clipboard_enabled {
"true"
} else {
"false"
});
text.push_str(",\n");
text.push_str(" // Retention in minutes (valid range: 5..43200)\n");
text.push_str(" \"clipboard_retention_minutes\": ");
text.push_str(&cfg.clipboard_retention_minutes.to_string());
text.push_str(",\n");
text.push_str(
" // Substring patterns that should be skipped when capturing clipboard entries\n",
);
text.push_str(" \"clipboard_exclude_sensitive_patterns\": [\n");
for (idx, pattern) in cfg.clipboard_exclude_sensitive_patterns.iter().enumerate() {
text.push_str(" ");
text.push_str(&json_string(pattern));
if idx + 1 != cfg.clipboard_exclude_sensitive_patterns.len() {
text.push(',');
}
text.push('\n');
}
text.push_str(" ],\n\n");
text.push_str(" // Plugin SDK settings\n");
text.push_str(" \"plugins_enabled\": ");
text.push_str(if cfg.plugins_enabled { "true" } else { "false" });
text.push_str(",\n");
text.push_str(" // Keep safe mode true to prevent plugin command execution.\n");
text.push_str(" \"plugins_safe_mode\": ");
text.push_str(if cfg.plugins_safe_mode {
"true"
} else {
"false"
});
text.push_str(",\n");
text.push_str(" // Game Mode: suppress the launcher hotkey while a likely game/fullscreen app is active.\n");
text.push_str(" \"game_mode_enabled\": ");
text.push_str(if cfg.game_mode_enabled {
"true"
} else {
"false"
});
text.push_str(",\n");
text.push_str(" \"plugin_paths\": [\n");
for (idx, path) in cfg.plugin_paths.iter().enumerate() {
text.push_str(" ");
text.push_str(&json_string(&path.to_string_lossy()));
if idx + 1 != cfg.plugin_paths.len() {
text.push(',');
}
text.push('\n');
}
text.push_str(" ],\n\n");
text.push_str(" // Runtime performance targets\n");
text.push_str(" // cache trim after hide in milliseconds (valid range: 100..10000)\n");
text.push_str(" \"idle_cache_trim_ms\": ");
text.push_str(&cfg.idle_cache_trim_ms.to_string());
text.push_str(",\n");
text.push_str(" // active memory target in MB (valid range: 20..512)\n");
text.push_str(" \"active_memory_target_mb\": ");
text.push_str(&cfg.active_memory_target_mb.to_string());
text.push_str(",\n");
text.push_str(" // Maximum indexed file/folder items retained in database discovery pass\n");
text.push_str(" \"index_max_items_total\": ");
text.push_str(&cfg.index_max_items_total.to_string());
text.push_str(",\n");
text.push_str(" // Maximum indexed file/folder items retained per discovery root\n");
text.push_str(" \"index_max_items_per_root\": ");
text.push_str(&cfg.index_max_items_per_root.to_string());
text.push_str(",\n");
text.push_str(" // Runtime candidate budget for per-query file/folder retrieval\n");
text.push_str(" \"index_max_items_per_query_seed\": ");
text.push_str(&cfg.index_max_items_per_query_seed.to_string());
text.push('\n');
text.push_str("}\n");
std::fs::write(path, text)?;
Ok(())
}
fn write_user_template_toml(cfg: &Config, path: &Path) -> Result<(), ConfigError> {
let roots_section = toml_path_array_section(&cfg.discovery_roots);
let excluded_roots_section = toml_path_array_section(&cfg.discovery_exclude_roots);
let plugin_paths_section = toml_path_array_section(&cfg.plugin_paths);
let clipboard_patterns_section =
toml_string_array_section(&cfg.clipboard_exclude_sensitive_patterns);
let mut text = String::new();
text.push_str("# Nex config (TOML format).\n");
text.push_str("#\n");
text.push_str("# How to use this file:\n");
text.push_str("# - Edit values and save.\n");
text.push_str("# - Most settings apply automatically within about 1 second.\n");
text.push_str("# - Restart required after changing hotkey or index_db_path.\n");
text.push_str("# - Strings must be in quotes (example: hotkey = \"Ctrl+Space\").\n");
text.push_str("# - Use double backslashes for Windows paths (C:\\\\Users\\\\Admin\\\\...).\n");
text.push_str("# - true/false and numbers are not quoted.\n");
text.push_str("# - This is the active config. Legacy config.json is kept only as backup.\n");
text.push_str("#\n");
text.push_str("# Quick setup:\n");
text.push_str("# 1) Keep exactly ONE hotkey value.\n");
text.push_str("# 2) Save file.\n");
text.push_str("# 3) Restart only if you changed hotkey/index_db_path.\n");
text.push_str("#\n");
text.push_str("# Safer Windows-friendly hotkeys you can use:\n");
for option in &cfg.hotkey_recommended {
text.push_str("# hotkey = ");
text.push_str(&json_string(option));
text.push('\n');
}
text.push_str("# Avoid common OS-reserved shortcuts like Win+..., Alt+Tab, Ctrl+Esc.\n");
text.push_str("hotkey = ");
text.push_str(&json_string(&cfg.hotkey));
text.push_str("\n\n");
text.push_str("# Start Nex automatically when you sign in (true/false)\n");
text.push_str("launch_at_startup = ");
text.push_str(if cfg.launch_at_startup {
"true"
} else {
"false"
});
text.push_str("\n\n");
text.push_str("# Number of results shown per query (valid range: 5..100)\n");
text.push_str("max_results = ");
text.push_str(&cfg.max_results.to_string());
text.push_str("\n\n");
text.push_str("# Folders scanned for local files.\n");
text.push_str("discovery_roots = ");
text.push_str(&roots_section);
text.push_str("\n\n");
text.push_str("# Folders excluded from local-file discovery.\n");
text.push_str("# Nex also skips common system/temp/dev-noise paths automatically\n");
text.push_str(
"# (for example: Windows, Program Files, AppData, node_modules, .git, __pycache__).\n",
);
text.push_str("# Built-in exclusions affect file/folder indexing only, not app discovery.\n");
text.push_str("discovery_exclude_roots = ");
text.push_str(&excluded_roots_section);
text.push_str("\n\n");
text.push_str("# Use Windows Search index for file/folder discovery when available.\n");
text.push_str("windows_search_enabled = ");
text.push_str(if cfg.windows_search_enabled {
"true"
} else {
"false"
});
text.push('\n');
text.push_str("# Fall back to direct filesystem scan when Windows Search is unavailable.\n");
text.push_str("windows_search_fallback_filesystem = ");
text.push_str(if cfg.windows_search_fallback_filesystem {
"true"
} else {
"false"
});
text.push_str("\n\n");
text.push_str("# Toggle file and folder visibility in results.\n");
text.push_str("show_files = ");
text.push_str(if cfg.show_files { "true" } else { "false" });
text.push('\n');
text.push_str("show_folders = ");
text.push_str(if cfg.show_folders { "true" } else { "false" });
text.push_str("\n\n");
text.push_str("# Search mode default: all | apps | files | actions | clipboard\n");
text.push_str("search_mode_default = ");
text.push_str(&json_string(match cfg.search_mode_default {
SearchMode::All => "all",
SearchMode::Apps => "apps",
SearchMode::Files => "files",
SearchMode::Actions => "actions",
SearchMode::Clipboard => "clipboard",
}));
text.push('\n');
text.push_str(
"# Enable query operators like kind:, modified:, created:, AND/OR/NOT and -term\n",
);
text.push_str("search_dsl_enabled = ");
text.push_str(if cfg.search_dsl_enabled {
"true"
} else {
"false"
});
text.push('\n');
text.push_str("# Delay query execution while typing for smoother UI updates\n");
text.push_str("search_query_results_with_delay = ");
text.push_str(if cfg.search_query_results_with_delay {
"true"
} else {
"false"
});
text.push('\n');
text.push_str("# Typing delay in milliseconds (valid range: 10..2000)\n");
text.push_str("search_delay_time_ms = ");
text.push_str(&cfg.search_delay_time_ms.to_string());
text.push('\n');
text.push_str("# Enable command mode uninstall actions (e.g. > uninstall appname)\n");
text.push_str("uninstall_actions_enabled = ");
text.push_str(if cfg.uninstall_actions_enabled {
"true"
} else {
"false"
});
text.push_str("\n\n");
text.push_str("# Web search in command mode (press > then type your query)\n");
text.push_str("# Default is google for most users.\n");
text.push_str(
"# Options: google | duckduckgo | bing | brave | startpage | ecosia | yahoo | custom\n",
);
text.push_str("web_search_provider = ");
text.push_str(&json_string(match cfg.web_search_provider {
WebSearchProvider::Duckduckgo => "duckduckgo",
WebSearchProvider::Google => "google",
WebSearchProvider::Bing => "bing",
WebSearchProvider::Brave => "brave",
WebSearchProvider::Startpage => "startpage",
WebSearchProvider::Ecosia => "ecosia",
WebSearchProvider::Yahoo => "yahoo",
WebSearchProvider::Custom => "custom",
}));
text.push('\n');
text.push_str("# Used only when provider is custom. Must include {query}.\n");
text.push_str("# Example: \"https://example.com/search?q={query}\"\n");
text.push_str("web_search_custom_template = ");
text.push_str(&json_string(&cfg.web_search_custom_template));
text.push_str("\n\n");
text.push_str("# Clipboard history provider settings\n");
text.push_str("clipboard_enabled = ");
text.push_str(if cfg.clipboard_enabled {
"true"
} else {
"false"
});
text.push('\n');
text.push_str("# Retention in minutes (valid range: 5..43200)\n");
text.push_str("clipboard_retention_minutes = ");
text.push_str(&cfg.clipboard_retention_minutes.to_string());
text.push('\n');
text.push_str("# Substring patterns skipped when capturing clipboard entries\n");
text.push_str("clipboard_exclude_sensitive_patterns = ");
text.push_str(&clipboard_patterns_section);
text.push_str("\n\n");
text.push_str("# Plugin SDK settings\n");
text.push_str("plugins_enabled = ");
text.push_str(if cfg.plugins_enabled { "true" } else { "false" });
text.push('\n');
text.push_str("# Keep safe mode true to prevent plugin command execution.\n");
text.push_str("plugins_safe_mode = ");
text.push_str(if cfg.plugins_safe_mode {
"true"
} else {
"false"
});
text.push('\n');
text.push_str(
"# Game Mode: suppress the launcher hotkey while a likely game/fullscreen app is active.\n",
);
text.push_str("game_mode_enabled = ");
text.push_str(if cfg.game_mode_enabled {
"true"
} else {
"false"
});
text.push('\n');
text.push_str("plugin_paths = ");
text.push_str(&plugin_paths_section);
text.push_str("\n\n");
text.push_str("# Runtime performance targets\n");
text.push_str("# cache trim after hide in milliseconds (valid range: 100..10000)\n");
text.push_str("idle_cache_trim_ms = ");
text.push_str(&cfg.idle_cache_trim_ms.to_string());
text.push('\n');
text.push_str("# active memory target in MB (valid range: 20..512)\n");
text.push_str("active_memory_target_mb = ");
text.push_str(&cfg.active_memory_target_mb.to_string());
text.push('\n');
text.push_str("# Maximum indexed file/folder items retained in discovery pass\n");
text.push_str("index_max_items_total = ");
text.push_str(&cfg.index_max_items_total.to_string());
text.push('\n');
text.push_str("# Maximum indexed file/folder items retained per discovery root\n");
text.push_str("index_max_items_per_root = ");
text.push_str(&cfg.index_max_items_per_root.to_string());
text.push('\n');
text.push_str("# Runtime candidate budget for per-query file/folder retrieval\n");
text.push_str("index_max_items_per_query_seed = ");
text.push_str(&cfg.index_max_items_per_query_seed.to_string());
text.push('\n');
std::fs::write(path, text)?;
Ok(())
}
pub fn validate(cfg: &Config) -> Result<(), String> {
if cfg.max_results < 5 || cfg.max_results > 100 {
return Err("max_results out of range".into());
}
if cfg.index_db_path.as_os_str().is_empty() {
return Err("index_db_path is required".into());
}
if cfg.config_path.as_os_str().is_empty() {
return Err("config_path is required".into());
}
if cfg.hotkey.trim().is_empty() {
return Err("hotkey is required".into());
}
if cfg.clipboard_retention_minutes < 5 || cfg.clipboard_retention_minutes > 43_200 {
return Err("clipboard_retention_minutes out of range".into());
}
if cfg.idle_cache_trim_ms < 100 || cfg.idle_cache_trim_ms > 10_000 {
return Err("idle_cache_trim_ms out of range".into());
}
if cfg.active_memory_target_mb < 20 || cfg.active_memory_target_mb > 512 {
return Err("active_memory_target_mb out of range".into());
}
if cfg.search_delay_time_ms < 10 || cfg.search_delay_time_ms > 2_000 {
return Err("search_delay_time_ms out of range".into());
}
if cfg.index_max_items_total < 10_000 || cfg.index_max_items_total > 2_000_000 {
return Err("index_max_items_total out of range".into());
}
if cfg.index_max_items_per_root < 1_000 || cfg.index_max_items_per_root > 1_000_000 {
return Err("index_max_items_per_root out of range".into());
}
if cfg.index_max_items_per_query_seed < 250 || cfg.index_max_items_per_query_seed > 200_000 {
return Err("index_max_items_per_query_seed out of range".into());
}
if cfg.index_max_items_per_root > cfg.index_max_items_total {
return Err("index_max_items_per_root must be <= index_max_items_total".into());
}
if cfg.web_search_provider == WebSearchProvider::Custom {
let template = cfg.web_search_custom_template.trim();
if template.is_empty() {
return Err(
"web_search_custom_template is required when web_search_provider=custom".into(),
);
}
if !template.contains("{query}") {
return Err("web_search_custom_template must include {query} placeholder".into());
}
}
if cfg
.discovery_roots
.iter()
.any(|root| root.as_os_str().is_empty())
{
return Err("discovery_roots contains an empty path".into());
}
if cfg
.discovery_exclude_roots
.iter()
.any(|root| root.as_os_str().is_empty())
{
return Err("discovery_exclude_roots contains an empty path".into());
}
if cfg
.plugin_paths
.iter()
.any(|path| path.as_os_str().is_empty())
{
return Err("plugin_paths contains an empty path".into());
}
if cfg
.clipboard_exclude_sensitive_patterns
.iter()
.any(|pattern| pattern.trim().is_empty())
{
return Err("clipboard_exclude_sensitive_patterns contains an empty pattern".into());
}
crate::settings::validate_hotkey(&cfg.hotkey)
.map_err(|error| format!("hotkey is invalid: {error}"))?;
if cfg.version == 0 {
return Err("version must be >= 1".into());
}
Ok(())
}
fn write_atomic(path: &Path, encoded: &str) -> Result<(), ConfigError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let temp_path = parent.join(format!(".nex-config-{ts}.tmp"));
let backup_path = parent.join(".nex-config.backup");
std::fs::write(&temp_path, encoded)?;
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
if path.exists() {
std::fs::rename(path, &backup_path)?;
}
match std::fs::rename(&temp_path, path) {
Ok(()) => {
if backup_path.exists() {
let _ = std::fs::remove_file(&backup_path);
}
Ok(())
}
Err(error) => {
if backup_path.exists() {
let _ = std::fs::rename(&backup_path, path);
}
let _ = std::fs::remove_file(&temp_path);
Err(ConfigError::Io(error))
}
}
}
fn json5_path_array_section(paths: &[PathBuf]) -> String {
let body = paths
.iter()
.map(|path| format!(" {}", json_string(&path.to_string_lossy())))
.collect::<Vec<_>>()
.join(",\n");
if body.is_empty() {
"[]".to_string()
} else {
format!("[\n{body}\n ]")
}
}
fn toml_path_array_section(paths: &[PathBuf]) -> String {
let values = paths
.iter()
.map(|path| path.to_string_lossy().to_string())
.collect::<Vec<_>>();
toml_string_array_section(&values)
}
fn toml_string_array_section(values: &[String]) -> String {
if values.is_empty() {
return "[]".to_string();
}
let body = values
.iter()
.map(|value| format!(" {}", json_string(value)))
.collect::<Vec<_>>()
.join(",\n");
format!("[\n{body},\n]")
}
fn default_discovery_roots() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
if let Some(profile_root) = windows_user_profile_root() {
return vec![profile_root];
}
}
Vec::new()
}
fn default_discovery_exclude_roots() -> Vec<PathBuf> {
#[cfg(target_os = "windows")]
{
if let Some(profile_root) = windows_user_profile_root() {
return vec![
profile_root.join("AppData").join("Local").join("Temp"),
profile_root
.join("AppData")
.join("Local")
.join("Microsoft")
.join("Windows")
.join("INetCache"),
];
}
}
Vec::new()
}
#[cfg(target_os = "windows")]
fn windows_user_profile_root() -> Option<PathBuf> {
if let Ok(user_profile) = std::env::var("USERPROFILE") {
let trimmed = user_profile.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
let home_drive = std::env::var("HOMEDRIVE").ok();
let home_path = std::env::var("HOMEPATH").ok();
if let (Some(drive), Some(path)) = (home_drive, home_path) {
let combined = format!("{}{}", drive.trim(), path.trim());
let trimmed = combined.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
None
}
fn default_for_path(path: &Path) -> Config {
let mut cfg = Config::default();
cfg.config_path = path.to_path_buf();
if cfg.index_db_path == Config::default().index_db_path {
cfg.index_db_path = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("index.sqlite3");
}
cfg
}
fn apply_migrations(cfg: &mut Config, raw: &str) -> bool {
let mut changed = false;
let source_version = cfg.version.max(1);
if cfg.version < CURRENT_CONFIG_VERSION {
cfg.version = CURRENT_CONFIG_VERSION;
changed = true;
}
if source_version < 2 {
let had_idle_key = raw_has_key(raw, "idle_cache_trim_ms");
let had_active_mem_key = raw_has_key(raw, "active_memory_target_mb");
if !had_idle_key || cfg.idle_cache_trim_ms == LEGACY_IDLE_CACHE_TRIM_MS_V1 {
cfg.idle_cache_trim_ms = Config::default().idle_cache_trim_ms;
changed = true;
}
if !had_active_mem_key || cfg.active_memory_target_mb == LEGACY_ACTIVE_MEMORY_TARGET_MB_V1 {
cfg.active_memory_target_mb = Config::default().active_memory_target_mb;
changed = true;
}
}
if source_version < 3 {
if !raw_has_key(raw, "web_search_provider") {
cfg.web_search_provider = Config::default().web_search_provider;
changed = true;
}
if !raw_has_key(raw, "web_search_custom_template") {
cfg.web_search_custom_template = Config::default().web_search_custom_template;
changed = true;
}
}
if source_version < 4 {
if !raw_has_key(raw, "windows_search_enabled") {
cfg.windows_search_enabled = Config::default().windows_search_enabled;
changed = true;
}
if !raw_has_key(raw, "windows_search_fallback_filesystem") {
cfg.windows_search_fallback_filesystem =
Config::default().windows_search_fallback_filesystem;
changed = true;
}
}
if source_version < 5 {
if !raw_has_key(raw, "show_files") {
cfg.show_files = Config::default().show_files;
changed = true;
}
if !raw_has_key(raw, "show_folders") {
cfg.show_folders = Config::default().show_folders;
changed = true;
}
}
if source_version < 6 && !raw_has_key(raw, "uninstall_actions_enabled") {
cfg.uninstall_actions_enabled = Config::default().uninstall_actions_enabled;
changed = true;
}
if source_version < 7 {
if !raw_has_key(raw, "index_max_items_total") {
cfg.index_max_items_total = Config::default().index_max_items_total;
changed = true;
}
if !raw_has_key(raw, "index_max_items_per_root") {
cfg.index_max_items_per_root = Config::default().index_max_items_per_root;
changed = true;
}
if !raw_has_key(raw, "index_max_items_per_query_seed") {
cfg.index_max_items_per_query_seed = Config::default().index_max_items_per_query_seed;
changed = true;
}
}
if source_version < 10 && !raw_has_key(raw, "game_mode_enabled") {
cfg.game_mode_enabled = Config::default().game_mode_enabled;
changed = true;
}
if source_version < 9 {
if !raw_has_key(raw, "search_query_results_with_delay") {
cfg.search_query_results_with_delay = Config::default().search_query_results_with_delay;
changed = true;
}
if !raw_has_key(raw, "search_delay_time_ms") {
cfg.search_delay_time_ms = Config::default().search_delay_time_ms;
changed = true;
}
}
if TEMPLATE_REQUIRED_KEYS
.iter()
.any(|key| !raw_has_key(raw, key))
{
changed = true;
}
if raw.contains(LEGACY_APP_DISPLAY_NAME) || raw.contains(LEGACY_APP_DIR_NAME_UNIX) {
changed = true;
}
changed
}
fn persist_migrated_config(
cfg: &Config,
path: &Path,
original_raw: &str,
source_version: u32,
source_path: &Path,
) -> Result<(), ConfigError> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent)?;
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let backup_ext = source_path
.extension()
.and_then(|ext| ext.to_str())
.filter(|ext| !ext.trim().is_empty())
.unwrap_or("txt");
let backup_path = parent.join(format!(
"config.v{}-backup-{}.{}",
source_version.max(1),
stamp,
backup_ext
));
std::fs::write(&backup_path, original_raw)?;
write_user_template(cfg, path)
}
fn raw_has_key(raw: &str, key: &str) -> bool {
let quoted = format!("\"{key}\"");
if raw.contains("ed) {
return true;
}
let toml_key = format!("{key} =");
if raw.contains(&toml_key) {
return true;
}
let bare = format!("{key}:");
raw.contains(&bare)
}
fn parse_text(raw: &str) -> Result<Config, ConfigError> {
match toml::from_str::<Config>(raw) {
Ok(cfg) => Ok(cfg),
Err(toml_err) => match serde_json::from_str::<Config>(raw) {
Ok(cfg) => Ok(cfg),
Err(json_err) => match json5::from_str::<Config>(raw) {
Ok(cfg) => Ok(cfg),
Err(json5_err) => Err(ConfigError::Parse(format!(
"invalid config format. toml error: {toml_err}; json error: {json_err}; json5 error: {json5_err}"
))),
},
},
}
}
fn json_string(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
#[cfg(target_os = "windows")]
fn windows_app_data_dir(app_dir_name: &str) -> Option<PathBuf> {
if let Ok(app_data) = std::env::var("APPDATA") {
return Some(PathBuf::from(app_data).join(app_dir_name));
}
if let Ok(user_profile) = std::env::var("USERPROFILE") {
return Some(
PathBuf::from(user_profile)
.join("AppData")
.join("Roaming")
.join(app_dir_name),
);
}
None
}
#[cfg(not(target_os = "windows"))]
fn unix_app_data_dir(app_dir_name: &str) -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
return Some(PathBuf::from(xdg).join(app_dir_name));
}
if let Ok(home) = std::env::var("HOME") {
return Some(PathBuf::from(home).join(".config").join(app_dir_name));
}
None
}
fn migrate_legacy_app_data_dir(preferred: PathBuf, legacy: Option<PathBuf>) -> PathBuf {
let Some(legacy) = legacy else {
return preferred;
};
if legacy == preferred || !legacy.exists() {
return preferred;
}
if !preferred.exists() {
if let Some(parent) = preferred.parent() {
let _ = std::fs::create_dir_all(parent);
}
return match std::fs::rename(&legacy, &preferred) {
Ok(()) => preferred,
Err(_) => legacy,
};
}
match move_missing_entries(&legacy, &preferred) {
Ok(()) => preferred,
Err(_) => {
if app_data_dir_has_state(&preferred) || !app_data_dir_has_state(&legacy) {
preferred
} else {
legacy
}
}
}
}
fn move_missing_entries(from: &Path, to: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if target.exists() {
continue;
}
std::fs::rename(entry.path(), target)?;
}
let is_empty = std::fs::read_dir(from)?.next().transpose()?.is_none();
if is_empty {
let _ = std::fs::remove_dir(from);
}
Ok(())
}
fn app_data_dir_has_state(path: &Path) -> bool {
[CONFIG_FILE_NAME, LEGACY_CONFIG_FILE_NAME, "index.sqlite3"]
.iter()
.any(|file_name| path.join(file_name).exists())
}
fn rewrite_managed_paths_to_current_app_dir(cfg: &mut Config) -> bool {
let current_dir = stable_app_data_dir();
#[cfg(target_os = "windows")]
let legacy_dir = windows_app_data_dir(LEGACY_APP_DIR_NAME_WINDOWS);
#[cfg(not(target_os = "windows"))]
let legacy_dir = unix_app_data_dir(LEGACY_APP_DIR_NAME_UNIX);
let Some(legacy_dir) = legacy_dir else {
return false;
};
if legacy_dir == current_dir {
return false;
}
let mut changed = false;
if let Some(rebased) = rebase_managed_path(&cfg.index_db_path, &legacy_dir, ¤t_dir) {
cfg.index_db_path = rebased;
changed = true;
}
for plugin_path in &mut cfg.plugin_paths {
if let Some(rebased) = rebase_managed_path(plugin_path, &legacy_dir, ¤t_dir) {
*plugin_path = rebased;
changed = true;
}
}
changed
}
fn rebase_managed_path(path: &Path, from_root: &Path, to_root: &Path) -> Option<PathBuf> {
let relative = path.strip_prefix(from_root).ok()?;
Some(to_root.join(relative))
}