search_hub

at 9ceb48b Raw

use figment::Figment;
use figment::providers::{Format, Toml};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;

use async_trait::async_trait;
use crate::search_engines::{EngineError, ResultEntry, SearchEngine};

/// Configuration for a single search engine instance.
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum EngineConfig {
    /// crates.io registry (public or private)
    #[serde(rename = "crates_io")]
    CratesIo {
        #[serde(default)]
        url: Option<String>,
        #[serde(default)]
        timeout_secs: Option<f32>,
        // Per-engine shortcut overrides (see `auto_shortcode()`, `bang_url()`, `is_bang_enabled()`)
        #[serde(default)]
        shortcode: Option<String>,
        #[serde(default)]
        bang_enabled: Option<bool>,
        #[serde(default)]
        bang_url: Option<String>,
    },
    /// SearXNG meta-search engine
    SearXng {
        instance: String,
        #[serde(default)]
        timeout_secs: Option<f32>,
        #[serde(default)]
        shortcode: Option<String>,
        #[serde(default)]
        bang_enabled: Option<bool>,
        #[serde(default)]
        bang_url: Option<String>,
    },
    /// Wikipedia search (language-specific)
    Wikipedia {
        /// Language code (e.g. "en", "fr", "de"). Defaults to "en" if omitted.
        #[serde(default)]
        lang: Option<String>,
        #[serde(default)]
        timeout_secs: Option<f32>,
        #[serde(default)]
        shortcode: Option<String>,
        #[serde(default)]
        bang_enabled: Option<bool>,
        #[serde(default)]
        bang_url: Option<String>,
    },
    /// MDN Web Docs search (language-specific)
    Mdn {
        /// Locale code (e.g. "en-US", "fr", "de"). Defaults to "en-US" if omitted.
        #[serde(default)]
        locale: Option<String>,
        #[serde(default)]
        timeout_secs: Option<f32>,
        #[serde(default)]
        shortcode: Option<String>,
        #[serde(default)]
        bang_enabled: Option<bool>,
        #[serde(default)]
        bang_url: Option<String>,
    },
    /// Generic HTML-scraped search engine (configurable URL + CSS selector)
    Generic {
        /// Display name for this engine (e.g. "DuckDuckGo", "Stack Overflow").
        name: String,
        /// URL template with `{}` placeholder for the query.
        url: String,
        /// CSS selector targeting the result container.
        selector: String,
        #[serde(default)]
        timeout_secs: Option<f32>,
        #[serde(default)]
        shortcode: Option<String>,
        #[serde(default)]
        bang_enabled: Option<bool>,
        #[serde(default)]
        bang_url: Option<String>,
    },
}

#[async_trait]
impl SearchEngine for EngineConfig {
    fn id(&self) -> &str {
        match self {
            EngineConfig::CratesIo { .. } => "crates.io",
            EngineConfig::SearXng { .. } => "searxng",
            EngineConfig::Wikipedia { .. } => "wikipedia",
            EngineConfig::Mdn { .. } => "mdn",
            EngineConfig::Generic { .. } => "generic",
        }
    }

    fn name(&self) -> &str {
        match self {
            EngineConfig::CratesIo { .. } => "crates.io",
            EngineConfig::SearXng { .. } => "SearXNG",
            EngineConfig::Wikipedia { .. } => "Wikipedia",
            EngineConfig::Mdn { .. } => "MDN",
            EngineConfig::Generic { name, .. } => name,
        }
    }

    fn url_template(&self) -> &str {
        match self {
            EngineConfig::Generic { url, .. } => url,
            _ => "",
        }
    }

    fn selector(&self) -> &str {
        match self {
            EngineConfig::Generic { selector, .. } => selector,
            _ => "",
        }
    }

    fn timeout(&self) -> Duration {
        let secs = match self {
            EngineConfig::CratesIo { timeout_secs, .. }
            | EngineConfig::SearXng { timeout_secs, .. }
            | EngineConfig::Wikipedia { timeout_secs, .. }
            | EngineConfig::Mdn { timeout_secs, .. }
            | EngineConfig::Generic { timeout_secs, .. } => timeout_secs,
        };
        secs.map(Duration::from_secs_f32).unwrap_or(Duration::from_secs(5))
    }

    async fn fetch_results(
        &self,
        query: &str,
        client: &reqwest::Client,
    ) -> Result<Vec<ResultEntry>, EngineError> {
        match self {
            EngineConfig::CratesIo { url, timeout_secs, .. } => {
                let engine = crate::search_engines::crates_io::CratesIo {
                    timeout_secs: *timeout_secs,
                    api_url: url.clone().unwrap_or_else(|| crate::search_engines::crates_io::DEFAULT_API_URL.into()),
                };
                engine.fetch_results(query, client).await
            }
            EngineConfig::SearXng { instance, timeout_secs, .. } => {
                let engine = crate::search_engines::searxng::SearXng {
                    instance: instance.clone(),
                    url_tpl: format!("{}/search?format=json&q={{}}", instance.trim_end_matches('/')),
                    timeout_secs: *timeout_secs,
                };
                engine.fetch_results(query, client).await
            }
            EngineConfig::Wikipedia { lang, timeout_secs, .. } => {
                let engine = crate::search_engines::wikipedia::Wikipedia {
                    lang: lang.clone().unwrap_or_else(|| crate::search_engines::wikipedia::DEFAULT_LANG.into()),
                    timeout_secs: *timeout_secs,
                };
                engine.fetch_results(query, client).await
            }
            EngineConfig::Mdn { locale, timeout_secs, .. } => {
                let engine = crate::search_engines::mdn::Mdn {
                    locale: locale.clone().unwrap_or_else(|| crate::search_engines::mdn::DEFAULT_LOCALE.into()),
                    timeout_secs: *timeout_secs,
                };
                engine.fetch_results(query, client).await
            }
            EngineConfig::Generic { name, url, selector, timeout_secs, .. } => {
                let engine = crate::search_engines::generic::Generic {
                    name: name.clone(),
                    url: url.clone(),
                    selector: selector.clone(),
                    timeout_secs: *timeout_secs,
                };
                engine.fetch_results(query, client).await
            }
        }
    }
}

/// A resolved shortcut linking a trigger string (!/@) to a bang URL and
/// optionally to a specific search engine.
#[derive(Debug, Clone, Serialize)]
pub struct Shortcut {
    /// The trigger keyword (e.g. "w" for !w).
    pub trigger: String,
    /// URL template with `{}` placeholder for the query.
    pub bang_url: String,
    /// Human-readable display name.
    pub name: String,
    /// If `Some(i)`, this shortcut originated from `engines[i]` and `@` works.
    pub engine_index: Option<usize>,
}

/// User-defined override or addition to auto-generated shortcuts.
#[derive(Debug, Deserialize, Clone)]
pub struct BangOverride {
    /// The trigger keyword (e.g. "w" for !w).
    pub trigger: String,
    /// Optional override URL template with `{}` placeholder.
    pub url: Option<String>,
    /// Optional display name override.
    pub name: Option<String>,
    /// Set to `false` to suppress an auto-generated shortcut.
    #[serde(default)]
    pub enabled: Option<bool>,
}

impl EngineConfig {
    /// Human-facing search-page URL for bang redirects (not the API URL).
    pub fn bang_url(&self) -> String {
        match self {
            EngineConfig::Wikipedia { lang, .. } => {
                let lang = lang.clone().unwrap_or_else(|| "en".into());
                format!("https://{lang}.wikipedia.org/w/index.php?search={{}}")
            }
            EngineConfig::Mdn { locale, .. } => {
                let locale = locale.clone().unwrap_or_else(|| "en-US".into());
                format!("https://developer.mozilla.org/{locale}/search?q={{}}")
            }
            EngineConfig::CratesIo { .. } => "https://crates.io/search?q={}".into(),
            EngineConfig::SearXng { instance, .. } => {
                format!("{}/search?q={{}}", instance.trim_end_matches('/'))
            }
            EngineConfig::Generic { url, .. } => url.clone(),
        }
    }

    /// Auto-generated shortcode derived from engine type and locale.
    pub fn auto_shortcode(&self) -> String {
        match self {
            EngineConfig::Wikipedia { lang, .. } => match lang.as_deref() {
                None | Some("en") => "w".into(),
                Some(l) => format!("w{l}"),
            },
            EngineConfig::Mdn { locale, .. } => match locale.as_deref() {
                None | Some("en-US") => "mdn".into(),
                Some(l) => {
                    let short = l.split('-').next().unwrap_or(l);
                    format!("mdn{short}")
                }
            },
            EngineConfig::CratesIo { .. } => "crates".into(),
            EngineConfig::SearXng { .. } => "sx".into(),
            EngineConfig::Generic { name, .. } => name
                .to_lowercase()
                .replace(|c: char| !c.is_alphanumeric() && c != '-', "-")
                .trim_matches('-')
                .to_string(),
        }
    }

    /// Effective shortcode, respecting user override on the engine.
    pub fn effective_shortcode(&self) -> String {
        match self {
            EngineConfig::CratesIo { shortcode, .. }
            | EngineConfig::SearXng { shortcode, .. }
            | EngineConfig::Wikipedia { shortcode, .. }
            | EngineConfig::Mdn { shortcode, .. }
            | EngineConfig::Generic { shortcode, .. } => {
                shortcode.clone().unwrap_or_else(|| self.auto_shortcode())
            }
        }
    }

    /// Effective bang URL, respecting user override on the engine.
    pub fn effective_bang_url(&self) -> String {
        match self {
            EngineConfig::CratesIo { bang_url, .. }
            | EngineConfig::SearXng { bang_url, .. }
            | EngineConfig::Wikipedia { bang_url, .. }
            | EngineConfig::Mdn { bang_url, .. }
            | EngineConfig::Generic { bang_url, .. } => {
                bang_url.clone().unwrap_or_else(|| self.bang_url())
            }
        }
    }

    /// Whether `!` bang redirect is enabled for this engine.
    pub fn is_bang_enabled(&self) -> bool {
        match self {
            EngineConfig::CratesIo { bang_enabled, .. }
            | EngineConfig::SearXng { bang_enabled, .. }
            | EngineConfig::Wikipedia { bang_enabled, .. }
            | EngineConfig::Mdn { bang_enabled, .. }
            | EngineConfig::Generic { bang_enabled, .. } => {
                bang_enabled.unwrap_or(true)
            }
        }
    }
}

/// Application configuration loaded from the TOML config file.
///
/// # Example
///
/// ```ignore
/// let cfg = search_hub::config::Config::load();
/// let engines = cfg.engines.clone();
/// println!("{} engines enabled", engines.len());
/// ```
#[derive(Debug, Default, Deserialize)]
pub struct Config {
    /// Custom tag definitions. If non-empty, these replace the hardcoded defaults.
    #[serde(default)]
    pub tags: Vec<crate::tagging::TagDef>,
    /// Whether auto-tagging is enabled. Defaults to `false` if not set.
    #[serde(default)]
    pub tagging_enabled: Option<bool>,
    /// Tagging threshold (0.0 to 1.0). Defaults to 0.60 if not set.
    #[serde(default)]
    pub tagging_threshold: Option<f64>,
    /// Hostnames to exclude from content fetching.
    #[serde(default)]
    pub exclude_urls: Option<Vec<String>>,
    /// Per-engine configuration. Each entry defines an enabled search engine.
    #[serde(default)]
    pub engines: Vec<EngineConfig>,
    /// User-defined shortcut overrides and additions.
    #[serde(default)]
    pub bangs: Vec<BangOverride>,
    /// Default bookmark database path.
    #[serde(default)]
    pub db_path: Option<String>,

    /// Server bind address (default: "127.0.0.1").
    #[serde(default)]
    pub bind_address: Option<String>,
    /// Results per page (default: 20).
    #[serde(default)]
    pub page_size: Option<usize>,
    /// Actix worker threads (default: 2).
    #[serde(default)]
    pub workers: Option<usize>,

    /// ONNX embedding model name (default: "BGESmallENV15").
    #[serde(default)]
    pub onnx_model: Option<String>,
    /// Max characters to use from page content for tagging (default: 2000).
    #[serde(default)]
    pub truncation: Option<usize>,
    /// Max tags to assign per bookmark (default: 5).
    #[serde(default)]
    pub max_tags: Option<usize>,
}

impl Config {
    /// Load configuration from the default config file path.
    ///
    /// Returns a default (empty) `Config` if the file doesn't exist or can't be parsed.
    /// Parse errors are printed to stderr.
    ///
    /// # Example
    ///
    /// ```ignore
    /// let cfg = search_hub::config::Config::load();
    /// ```
    pub fn load() -> Self {
        Self::load_from(&config_file_path())
    }

    /// Load configuration from a specific file path.
    ///
    /// Returns a default (empty) `Config` if the file doesn't exist or can't be parsed.
    /// Parse errors are printed to stderr.
    ///
    /// # Example
    ///
    /// ```ignore
    /// let cfg = search_hub::config::Config::load_from(&PathBuf::from("/tmp/test.toml"));
    /// ```
    pub fn load_from(path: &PathBuf) -> Self {
        if path.exists() {
            Figment::new()
                .merge(Toml::file(path))
                .extract()
                .unwrap_or_else(|e| {
                    eprintln!("Warning: failed to parse config file {path:?}: {e}");
                    Config::default()
                })
        } else {
            Config::default()
        }
    }

    /// Resolve all shortcuts by combining auto-generated engine shortcodes
    /// with user-defined `[[bangs]]` overrides.
    ///
    /// Two-phase resolution:
    ///   1. Walk engines, auto-generate or use per-engine `shortcode` override.
    ///   2. Walk `[[bangs]]` entries: disable existing, add new, or merge
    ///      (replace url/name on an auto-generated entry).
    ///
    /// # Panics
    ///
    /// Panics if two engines produce the same shortcode. Set `shortcode` on
    /// one of them to resolve the conflict.
    pub fn resolve_shortcuts(&self) -> HashMap<String, Shortcut> {
        let mut map: HashMap<String, Shortcut> = HashMap::new();

        // Phase 1: auto-generate from configured engines
        for (i, engine) in self.engines.iter().enumerate() {
            if !engine.is_bang_enabled() {
                continue;
            }
            let trigger = engine.effective_shortcode();
            let bang_url = engine.effective_bang_url();
            let name = engine.name().to_string();

            if let Some(existing) = map.get(&trigger) {
                panic!(
                    "Shortcode conflict: '{trigger}' is used by engine {existing_idx:?} \
                     ({existing_name}) and engine #{i} ({name}). \
                     Set `shortcode` on one of them to resolve.",
                    existing_idx = existing.engine_index,
                    existing_name = existing.name,
                );
            }

            map.insert(
                trigger.clone(),
                Shortcut {
                    trigger,
                    bang_url,
                    name,
                    engine_index: Some(i),
                },
            );
        }

        // Phase 2: apply user-defined [[bangs]] overrides/additions
        for bang in &self.bangs {
            let enabled = bang.enabled.unwrap_or(true);
            if !enabled {
                map.remove(&bang.trigger);
                continue;
            }
            let entry = map.entry(bang.trigger.clone()).or_insert_with(|| Shortcut {
                trigger: bang.trigger.clone(),
                bang_url: String::new(),
                name: String::new(),
                engine_index: None,
            });
            if let Some(url) = &bang.url {
                entry.bang_url = url.clone();
            }
            if let Some(name) = &bang.name {
                entry.name = name.clone();
            }
        }

        map
    }
}



/// Return the expected config file path (e.g. `~/.config/search_hub/config.toml` on Linux).
///
/// # Example
///
/// ```ignore
/// let path = search_hub::config::config_file_path();
/// ```
///
/// # Panics
///
/// Panics if the platform has no valid config directory.
pub fn config_file_path() -> PathBuf {
    let dirs = directories::ProjectDirs::from("com", "search_hub", "search_hub")
        .expect("no valid config directory");
    let config_dir = dirs.config_dir();
    config_dir.join("config.toml")
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;
    use std::io::Write;

    #[test]
    fn load_from_missing_file_returns_default() {
        let cfg = Config::load_from(&PathBuf::from("/nonexistent/path.toml"));
        assert!(cfg.tags.is_empty());
        assert!(cfg.engines.is_empty());
    }

    #[test]
    fn load_from_valid_file_with_engines() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"
[[engines]]
type = "searxng"
instance = "https://search.example.com"
"#).unwrap();

        let cfg = Config::load_from(&file.path().to_path_buf());
        assert!(!cfg.engines.is_empty());
        assert!(matches!(cfg.engines[0], EngineConfig::SearXng { .. }));
        if let EngineConfig::SearXng { instance, .. } = &cfg.engines[0] {
            assert_eq!(instance, "https://search.example.com");
        } else {
            panic!("expected SearXng");
        }
    }

    #[test]
    fn resolve_engines_includes_searxng_from_engines_vec() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"
[[engines]]
type = "searxng"
instance = "https://search.example.com"
"#).unwrap();

        let cfg = Config::load_from(&file.path().to_path_buf());
        let engines = cfg.engines.clone();
        assert!(engines.iter().any(|e| e.id() == "searxng"));
    }

    #[test]
    fn resolve_engines_empty_by_default() {
        let cfg = Config::default();
        assert!(cfg.engines.is_empty());
    }

    #[test]
    fn resolve_engines_includes_crates_io_when_configured() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"
[[engines]]
type = "crates_io"
"#).unwrap();

        let cfg = Config::load_from(&file.path().to_path_buf());
        let engines = cfg.engines.clone();
        assert!(engines.iter().any(|e| e.id() == "crates.io"));
    }

    #[test]
    fn resolve_engines_includes_wikipedia_when_configured() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"
[[engines]]
type = "wikipedia"
lang = "fr"
"#).unwrap();

        let cfg = Config::load_from(&file.path().to_path_buf());
        let engines = cfg.engines.clone();
        assert!(engines.iter().any(|e| e.id() == "wikipedia"));
    }

    #[test]
    fn resolve_engines_respects_multiple_engines() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"
[[engines]]
type = "crates_io"

[[engines]]
type = "searxng"
instance = "https://search.example.com"
"#).unwrap();

        let cfg = Config::load_from(&file.path().to_path_buf());
        let engines = cfg.engines.clone();
        assert!(engines.iter().any(|e| e.id() == "crates.io"));
        assert!(engines.iter().any(|e| e.id() == "searxng"));
    }

    #[test]
    fn parse_error_returns_default() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, "invalid toml [[[").unwrap();
        let cfg = Config::load_from(&file.path().to_path_buf());
        assert!(cfg.tags.is_empty());
        assert!(cfg.engines.is_empty());
    }

    #[test]
    fn tagging_enabled_defaults_to_false() {
        let cfg = Config::default();
        assert!(!cfg.tagging_enabled.unwrap_or(false));
    }

    #[test]
    fn tagging_enabled_can_be_false() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"tagging_enabled = false"#).unwrap();
        let cfg = Config::load_from(&file.path().to_path_buf());
        assert_eq!(cfg.tagging_enabled, Some(false));
    }

    #[test]
    fn tagging_enabled_can_be_true() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"tagging_enabled = true"#).unwrap();
        let cfg = Config::load_from(&file.path().to_path_buf());
        assert_eq!(cfg.tagging_enabled, Some(true));
    }

    // -- shortcut (bang/@) tests --

    #[test]
    fn auto_shortcode_wikipedia_defaults_to_w() {
        let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "w");
    }

    #[test]
    fn auto_shortcode_wikipedia_lang_appended() {
        let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "wfr");
    }

    #[test]
    fn auto_shortcode_wikipedia_en_is_w() {
        let e = EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "w");
    }

    #[test]
    fn auto_shortcode_mdn_defaults_to_mdn() {
        let e = EngineConfig::Mdn { locale: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "mdn");
    }

    #[test]
    fn auto_shortcode_mdn_locale_appended() {
        let e = EngineConfig::Mdn { locale: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "mdnfr");
    }

    #[test]
    fn auto_shortcode_crates_io() {
        let e = EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "crates");
    }

    #[test]
    fn auto_shortcode_searxng() {
        let e = EngineConfig::SearXng { instance: "https://example.com".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "sx");
    }

    #[test]
    fn auto_shortcode_generic_slugifies_name() {
        let e = EngineConfig::Generic { name: "Stack Overflow".into(), url: "".into(), selector: "".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.auto_shortcode(), "stack-overflow");
    }

    #[test]
    fn effective_shortcode_respects_override() {
        let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: Some("wikifr".into()), bang_enabled: None, bang_url: None };
        assert_eq!(e.effective_shortcode(), "wikifr");
    }

    #[test]
    fn bang_url_wikipedia() {
        let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.bang_url(), "https://en.wikipedia.org/w/index.php?search={}");
    }

    #[test]
    fn bang_url_wikipedia_fr() {
        let e = EngineConfig::Wikipedia { lang: Some("fr".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.bang_url(), "https://fr.wikipedia.org/w/index.php?search={}");
    }

    #[test]
    fn bang_url_crates_io() {
        let e = EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.bang_url(), "https://crates.io/search?q={}");
    }

    #[test]
    fn bang_url_searxng() {
        let e = EngineConfig::SearXng { instance: "https://search.example.com".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.bang_url(), "https://search.example.com/search?q={}");
    }

    #[test]
    fn bang_url_mdn() {
        let e = EngineConfig::Mdn { locale: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.bang_url(), "https://developer.mozilla.org/en-US/search?q={}");
    }

    #[test]
    fn bang_url_generic_uses_provided_url() {
        let e = EngineConfig::Generic { name: "Test".into(), url: "https://example.com/search?q={}".into(), selector: "".into(), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert_eq!(e.bang_url(), "https://example.com/search?q={}");
    }

    #[test]
    fn effective_bang_url_respects_override() {
        let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: Some("https://custom.example.com/?q={}".into()) };
        assert_eq!(e.effective_bang_url(), "https://custom.example.com/?q={}");
    }

    #[test]
    fn is_bang_enabled_defaults_true() {
        let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None };
        assert!(e.is_bang_enabled());
    }

    #[test]
    fn is_bang_enabled_false_when_set() {
        let e = EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: Some(false), bang_url: None };
        assert!(!e.is_bang_enabled());
    }

    #[test]
    fn resolve_shortcuts_from_single_engine() {
        let cfg = Config {
            engines: vec![
                EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert_eq!(sc.len(), 1);
        assert!(sc.contains_key("crates"));
        assert_eq!(sc["crates"].engine_index, Some(0));
    }

    #[test]
    fn resolve_shortcuts_multiple_engines() {
        let cfg = Config {
            engines: vec![
                EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
                EngineConfig::CratesIo { url: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert_eq!(sc.len(), 2);
        assert!(sc.contains_key("w"));
        assert!(sc.contains_key("crates"));
    }

    #[test]
    fn resolve_shortcuts_respects_engine_shortcode_override() {
        let cfg = Config {
            engines: vec![
                EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: Some("wiki".into()), bang_enabled: None, bang_url: None },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert!(sc.contains_key("wiki"));
        assert!(!sc.contains_key("w"));
    }

    #[test]
    fn resolve_shortcuts_respects_engine_bang_disabled() {
        let cfg = Config {
            engines: vec![
                EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: Some(false), bang_url: None },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert!(sc.is_empty());
    }

    #[test]
    fn resolve_shortcuts_user_bang_override_replaces_url() {
        let cfg = Config {
            engines: vec![
                EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
            ],
            bangs: vec![
                BangOverride { trigger: "w".into(), url: Some("https://custom.example.com/?q={}".into()), name: None, enabled: None },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert_eq!(sc["w"].bang_url, "https://custom.example.com/?q={}");
    }

    #[test]
    fn resolve_shortcuts_user_bang_disables_auto() {
        let cfg = Config {
            engines: vec![
                EngineConfig::Wikipedia { lang: None, timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
            ],
            bangs: vec![
                BangOverride { trigger: "w".into(), url: None, name: None, enabled: Some(false) },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert!(!sc.contains_key("w"));
    }

    #[test]
    fn resolve_shortcuts_user_bang_adds_new() {
        let cfg = Config {
            bangs: vec![
                BangOverride { trigger: "gh".into(), url: Some("https://github.com/search?q={}".into()), name: Some("GitHub".into()), enabled: None },
            ],
            ..Config::default()
        };
        let sc = cfg.resolve_shortcuts();
        assert!(sc.contains_key("gh"));
        assert_eq!(sc["gh"].engine_index, None);
        assert_eq!(sc["gh"].bang_url, "https://github.com/search?q={}");
    }

    #[test]
    fn resolve_shortcuts_parses_bangs_from_config_file() {
        let mut file = NamedTempFile::new().unwrap();
        write!(file, r#"
[[engines]]
type = "wikipedia"

[[bangs]]
trigger = "gh"
url = "https://github.com/search?q={{}}"
name = "GitHub"
"#).unwrap();
        let cfg = Config::load_from(&file.path().to_path_buf());
        let sc = cfg.resolve_shortcuts();
        assert!(sc.contains_key("w"));
        assert!(sc.contains_key("gh"));
        assert_eq!(sc["gh"].engine_index, None);
    }

    #[test]
    #[should_panic(expected = "Shortcode conflict")]
    fn resolve_shortcuts_panics_on_collision() {
        let cfg = Config {
            engines: vec![
                EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
                EngineConfig::Wikipedia { lang: Some("en".into()), timeout_secs: None, shortcode: None, bang_enabled: None, bang_url: None },
            ],
            ..Config::default()
        };
        cfg.resolve_shortcuts();
    }
}