velesdb-cli 5.2.0

Interactive CLI and REPL for VelesDB with VelesQL support
//! Session configuration for VelesDB REPL.
//!
//! Manages session-level settings that can be modified with `\set` and viewed with `\show`.

use std::collections::HashMap;
use velesdb_core::SearchQuality;

/// Session settings for the REPL.
#[derive(Debug, Clone)]
pub struct SessionSettings {
    /// Current search mode.
    mode: SearchQuality,
    /// Override ef_search (None = use mode default).
    ef_search: Option<usize>,
    /// Query timeout in milliseconds.
    timeout_ms: u64,
    /// Enable reranking after quantized search.
    rerank: bool,
    /// Maximum results per query.
    max_results: usize,
    /// Active collection (for \use command).
    active_collection: Option<String>,
    /// Custom settings.
    custom: HashMap<String, String>,
}

impl Default for SessionSettings {
    fn default() -> Self {
        Self {
            mode: SearchQuality::Balanced,
            ef_search: None,
            timeout_ms: 30000,
            rerank: true,
            max_results: 100,
            active_collection: None,
            custom: HashMap::new(),
        }
    }
}

impl SessionSettings {
    /// Creates new session settings with defaults.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Gets the current search mode.
    #[must_use]
    pub fn mode(&self) -> SearchQuality {
        self.mode
    }

    /// Returns the current mode as the `WITH(mode=...)` string the core parser
    /// understands (`fast`/`balanced`/`accurate`/`perfect`/`autotune`/`custom:<ef>`/
    /// `adaptive:<min>:<max>`), so the session mode can be injected into a query.
    #[must_use]
    pub fn mode_str(&self) -> String {
        format_quality(self.mode)
    }

    /// Gets the explicitly-set ef_search override, if any.
    ///
    /// `None` means "use the mode default"; the session only injects an explicit
    /// `ef_search` into a query's WITH clause, leaving the mode to drive the rest.
    #[must_use]
    pub fn ef_search(&self) -> Option<usize> {
        self.ef_search
    }

    /// Gets the effective ef_search value.
    ///
    /// Uses a default `k=10` for the quality profile's ef calculation. The query
    /// path injects the explicit [`Self::ef_search`] override and lets the `mode`
    /// drive the rest, so this resolved value is used only in tests.
    #[must_use]
    #[allow(dead_code)] // Reason: resolved ef preview for tests; query path injects the explicit override + mode
    pub fn effective_ef_search(&self) -> usize {
        self.ef_search.unwrap_or_else(|| self.mode.ef_search(10))
    }

    /// Gets the query timeout in milliseconds.
    #[must_use]
    #[allow(dead_code)] // Reason: public API for session-aware query execution (used in tests)
    pub fn timeout_ms(&self) -> u64 {
        self.timeout_ms
    }

    /// Gets the rerank setting.
    #[must_use]
    #[allow(dead_code)] // Reason: public API for session-aware query execution (used in tests)
    pub fn rerank(&self) -> bool {
        self.rerank
    }

    /// Gets max results.
    #[must_use]
    pub fn max_results(&self) -> usize {
        self.max_results
    }

    /// Gets the active collection.
    #[must_use]
    pub fn active_collection(&self) -> Option<&str> {
        self.active_collection.as_deref()
    }

    /// Sets a session parameter.
    ///
    /// # Returns
    ///
    /// Ok(()) if the parameter was set, Err(message) if invalid.
    pub fn set(&mut self, key: &str, value: &str) -> Result<(), String> {
        match key.to_lowercase().as_str() {
            "mode" => {
                self.mode = parse_mode(value)?;
                self.ef_search = None; // Reset ef_search when mode changes
                Ok(())
            }
            "ef_search" => {
                let ef = value
                    .parse::<usize>()
                    .map_err(|_| format!("Invalid integer: {value}"))?;
                if !(16..=4096).contains(&ef) {
                    return Err(format!("ef_search must be between 16 and 4096, got {ef}"));
                }
                self.ef_search = Some(ef);
                Ok(())
            }
            "timeout_ms" | "timeout" => {
                let ms = value
                    .parse::<u64>()
                    .map_err(|_| format!("Invalid integer: {value}"))?;
                if ms < 100 {
                    return Err("timeout_ms must be at least 100".to_string());
                }
                self.timeout_ms = ms;
                Ok(())
            }
            "rerank" => {
                self.rerank = parse_bool(value)?;
                Ok(())
            }
            "max_results" => {
                let max = value
                    .parse::<usize>()
                    .map_err(|_| format!("Invalid integer: {value}"))?;
                if max == 0 || max > 10000 {
                    return Err("max_results must be between 1 and 10000".to_string());
                }
                self.max_results = max;
                Ok(())
            }
            _ => {
                self.custom.insert(key.to_string(), value.to_string());
                Ok(())
            }
        }
    }

    /// Sets the active collection.
    pub fn use_collection(&mut self, name: Option<String>) {
        self.active_collection = name;
    }

    /// Resets a specific setting or all settings.
    pub fn reset(&mut self, key: Option<&str>) {
        match key {
            None => {
                *self = Self::default();
            }
            Some(k) => match k.to_lowercase().as_str() {
                "mode" => self.mode = SearchQuality::Balanced,
                "ef_search" => self.ef_search = None,
                "timeout_ms" | "timeout" => self.timeout_ms = 30000,
                "rerank" => self.rerank = true,
                "max_results" => self.max_results = 100,
                "collection" => self.active_collection = None,
                _ => {
                    self.custom.remove(k);
                }
            },
        }
    }

    /// Returns all settings as displayable key-value pairs.
    #[must_use]
    pub fn all_settings(&self) -> Vec<(String, String)> {
        let mut settings = vec![
            ("mode".to_string(), format_quality(self.mode)),
            (
                "ef_search".to_string(),
                self.ef_search.map_or_else(
                    || format!("auto ({})", self.mode.ef_search(10)),
                    |v| v.to_string(),
                ),
            ),
            ("timeout_ms".to_string(), self.timeout_ms.to_string()),
            ("rerank".to_string(), self.rerank.to_string()),
            ("max_results".to_string(), self.max_results.to_string()),
            (
                "collection".to_string(),
                self.active_collection
                    .clone()
                    .unwrap_or_else(|| "(none)".to_string()),
            ),
        ];

        for (k, v) in &self.custom {
            settings.push((k.clone(), v.clone()));
        }

        settings
    }

    /// Gets a single setting value.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<String> {
        match key.to_lowercase().as_str() {
            "mode" => Some(format_quality(self.mode)),
            "ef_search" => Some(self.ef_search.map_or_else(
                || format!("auto ({})", self.mode.ef_search(10)),
                |v| v.to_string(),
            )),
            "timeout_ms" | "timeout" => Some(self.timeout_ms.to_string()),
            "rerank" => Some(self.rerank.to_string()),
            "max_results" => Some(self.max_results.to_string()),
            "collection" => Some(
                self.active_collection
                    .clone()
                    .unwrap_or_else(|| "(none)".to_string()),
            ),
            _ => self.custom.get(key).cloned(),
        }
    }
}

/// Formats a `SearchQuality` for display in session settings.
fn format_quality(q: SearchQuality) -> String {
    match q {
        SearchQuality::Fast => "fast".to_string(),
        SearchQuality::Balanced => "balanced".to_string(),
        SearchQuality::Accurate => "accurate".to_string(),
        SearchQuality::Perfect => "perfect".to_string(),
        SearchQuality::AutoTune => "autotune".to_string(),
        SearchQuality::Custom(ef) => format!("custom:{ef}"),
        SearchQuality::Adaptive { min_ef, max_ef } => {
            format!("adaptive:{min_ef}:{max_ef}")
        }
        _ => format!("{q:?}").to_lowercase(),
    }
}

fn parse_mode(value: &str) -> Result<SearchQuality, String> {
    let lower = value.to_lowercase();
    match lower.as_str() {
        "fast" => Ok(SearchQuality::Fast),
        "balanced" => Ok(SearchQuality::Balanced),
        "accurate" => Ok(SearchQuality::Accurate),
        "perfect" => Ok(SearchQuality::Perfect),
        "autotune" => Ok(SearchQuality::AutoTune),
        _ => parse_parameterized_mode(&lower),
    }
}

/// Parses `custom:<ef>` and `adaptive:<min>:<max>` mode strings.
fn parse_parameterized_mode(value: &str) -> Result<SearchQuality, String> {
    if let Some(ef_str) = value.strip_prefix("custom:") {
        let ef = ef_str
            .parse::<usize>()
            .map_err(|_| format!("Invalid ef value in 'custom:{ef_str}'"))?;
        return Ok(SearchQuality::Custom(ef));
    }
    if let Some(rest) = value.strip_prefix("adaptive:") {
        let parts: Vec<&str> = rest.splitn(2, ':').collect();
        if parts.len() != 2 {
            return Err("adaptive format: adaptive:<min_ef>:<max_ef>".to_string());
        }
        let min_ef = parts[0]
            .parse::<usize>()
            .map_err(|_| format!("Invalid min_ef in '{value}'"))?;
        let max_ef = parts[1]
            .parse::<usize>()
            .map_err(|_| format!("Invalid max_ef in '{value}'"))?;
        return Ok(SearchQuality::Adaptive { min_ef, max_ef });
    }
    Err(format!(
        "Invalid mode '{value}'. Valid: fast, balanced, accurate, \
         perfect, autotune, custom:<ef>, adaptive:<min>:<max>"
    ))
}

/// Returns `true` for settings that `\set` stores and displays but that have no
/// channel into `Database::execute_query` today, so the REPL warns they are
/// display-only rather than silently claiming they affect queries.
///
/// `mode`, `ef_search` and `max_results` ARE wired (injected into the query AST
/// / applied as a LIMIT cap); `timeout_ms` and `rerank` are not.
#[must_use]
pub fn is_unwired_setting(key: &str) -> bool {
    matches!(
        key.to_lowercase().as_str(),
        "timeout_ms" | "timeout" | "rerank"
    )
}

fn parse_bool(value: &str) -> Result<bool, String> {
    match value.to_lowercase().as_str() {
        "true" | "on" | "1" | "yes" => Ok(true),
        "false" | "off" | "0" | "no" => Ok(false),
        _ => Err(format!(
            "Invalid boolean '{}'. Use true/false, on/off, 1/0",
            value
        )),
    }
}

#[cfg(test)]
#[path = "session_tests.rs"]
mod session_tests;