ublx 0.1.3

TUI to index once, enrich with metadata, and browse a flat snapshot in a 3-pane layout with multiple modes.
Documentation
use std::fs;
use std::path::Path;

use crate::config::UblxPaths;
use crate::integrations::{ZahirFT, file_type_from_metadata_name};

/// Schema for the ublx DB
pub struct UblxDbSchema;

impl UblxDbSchema {
    /// Snapshot table: one row per path. Nefaxer columns + ublx category + optional zahirscan JSON.
    pub const CREATE_SNAPSHOT: &'static str = "
CREATE TABLE IF NOT EXISTS snapshot (
    path TEXT PRIMARY KEY,
    mtime_ns INTEGER NOT NULL,
    size INTEGER NOT NULL,
    hash BLOB,
    category TEXT,
    zahir_json TEXT
);
";

    /// Settings table: single row storing disk/tuning so we can skip disk check when .ublx exists.
    /// `config_source`: 'local' | 'global' when global config exists; which config to use (stored in .ublx).
    pub const CREATE_SETTINGS: &'static str = "
CREATE TABLE IF NOT EXISTS settings (
    id INTEGER PRIMARY KEY CHECK (id = 1),
    num_threads INTEGER NOT NULL,
    drive_type TEXT NOT NULL,
    parallel_walk INTEGER NOT NULL,
    config_source TEXT
);
";

    /// Delta log: one row per change (added, mod, removed); no zahir result, just path + meta.
    pub const CREATE_DELTA_LOG: &'static str = "
CREATE TABLE IF NOT EXISTS delta_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    created_ns INTEGER NOT NULL,
    path TEXT NOT NULL,
    mtime_ns INTEGER,
    size INTEGER,
    hash BLOB,
    delta_type TEXT NOT NULL CHECK (delta_type IN ('added', 'mod', 'removed'))
);
";

    /// Normalized path strings for lens membership (one row per distinct path).
    pub const CREATE_PATH: &'static str = "
CREATE TABLE IF NOT EXISTS path (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT NOT NULL UNIQUE
);
";

    /// Lenses (playlists): one row per lens, id + name.
    pub const CREATE_LENS: &'static str = "
CREATE TABLE IF NOT EXISTS lens (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL UNIQUE
);
";

    /// Which paths are in which lens, and in what order (`lens_id`, `path_id`, position).
    pub const CREATE_LENS_PATH: &'static str = "
CREATE TABLE IF NOT EXISTS lens_path (
    lens_id INTEGER NOT NULL REFERENCES lens(id) ON DELETE CASCADE,
    path_id INTEGER NOT NULL REFERENCES path(id) ON DELETE CASCADE,
    position INTEGER NOT NULL,
    PRIMARY KEY (lens_id, path_id)
);
CREATE INDEX IF NOT EXISTS idx_lens_path_lens_position ON lens_path (lens_id, position);
";

    /// SQL to create all ublx tables (snapshot, settings, `delta_log`, path, lens, `lens_path`). Use when opening or creating the DB.
    #[must_use]
    pub fn create_ublx_db_sql() -> String {
        format!(
            "{}{}{}{}{}{}",
            Self::CREATE_SNAPSHOT,
            Self::CREATE_SETTINGS,
            Self::CREATE_DELTA_LOG,
            Self::CREATE_PATH,
            Self::CREATE_LENS,
            Self::CREATE_LENS_PATH,
        )
    }
}

/// Statements for the ublx DB
pub struct UblxDbStatements;

impl UblxDbStatements {
    pub const INSERT_SNAPSHOT: &'static str = "INSERT OR REPLACE INTO snapshot (path, mtime_ns, size, hash, category, zahir_json) VALUES (?1, ?2, ?3, ?4, ?5, ?6)";

    /// Update category and `zahir_json` for an existing snapshot row (legacy; prefer [`Self::UPDATE_SNAPSHOT_ZAHIR_JSON_ONLY`] when category must stay unchanged).
    pub const UPDATE_SNAPSHOT_ZAHIR: &'static str =
        "UPDATE snapshot SET category = ?1, zahir_json = ?2 WHERE path = ?3";

    /// Update only `zahir_json` (category stays as set at insert / prior snapshot).
    pub const UPDATE_SNAPSHOT_ZAHIR_JSON_ONLY: &'static str =
        "UPDATE snapshot SET zahir_json = ?1 WHERE path = ?2";

    /// User rename on disk: repoint PK and refresh metadata without a full index (`hash` cleared until next full run).
    pub const UPDATE_SNAPSHOT_RENAME_IN_PLACE: &'static str = "UPDATE snapshot SET path = ?1, mtime_ns = ?2, size = ?3, hash = ?4, category = ?5, zahir_json = ?6 WHERE path = ?7";

    /// Set `hash` (32-byte blake3) for a row when duplicates scan fills missing hashes from disk.
    pub const UPDATE_SNAPSHOT_HASH_BY_PATH: &'static str =
        "UPDATE snapshot SET hash = ?1 WHERE path = ?2";

    pub const DELETE_SNAPSHOT_ROW: &'static str = "DELETE FROM snapshot WHERE path = ?1";

    pub const INSERT_SETTINGS: &'static str = "INSERT OR REPLACE INTO settings (id, num_threads, drive_type, parallel_walk, config_source) VALUES (1, ?1, ?2, ?3, ?4)";

    pub const INSERT_DELTA_LOG: &'static str = "INSERT INTO delta_log (created_ns, path, mtime_ns, size, hash, delta_type) VALUES (?1, ?2, ?3, ?4, ?5, ?6)";

    /// After a user rename on disk: keep historical `delta_log` rows aligned with the new path before appending removed/added rows.
    pub const UPDATE_DELTA_LOG_PATH: &'static str =
        "UPDATE delta_log SET path = ?1 WHERE path = ?2";

    pub const COPY_PREVIOUS_DELTA_LOG: &'static str = "INSERT INTO main.delta_log (created_ns, path, mtime_ns, size, hash, delta_type) SELECT created_ns, path, mtime_ns, size, hash, delta_type FROM old.delta_log";

    pub const ATTACH_OLD_DB: &'static str = "ATTACH DATABASE ?1 AS old";

    pub const DETACH_OLD_DB: &'static str = "DETACH DATABASE old";

    pub const SELECT_COUNT_DELTA_LOG_ROWS: &'static str =
        "SELECT COUNT(*) FROM old.sqlite_master WHERE type='table' AND name='delta_log'";

    /// Check if old DB has lens table (so we can copy `path/lens/lens_path`).
    pub const SELECT_LENS_TABLE_EXISTS: &'static str =
        "SELECT COUNT(*) FROM old.sqlite_master WHERE type='table' AND name='lens'";
    /// Copy path strings from old DB; new path ids are assigned in main.
    pub const COPY_PREVIOUS_PATH: &'static str =
        "INSERT INTO main.path(path) SELECT path FROM old.path";
    /// Copy lens names from old DB; new lens ids are assigned in main.
    pub const COPY_PREVIOUS_LENS: &'static str =
        "INSERT INTO main.lens(name) SELECT name FROM old.lens";
    /// Copy `lens_path` rows, mapping old `lens_id/path_id` to new ids by matching name/path.
    pub const COPY_PREVIOUS_LENS_PATH: &'static str = "INSERT INTO main.lens_path(lens_id, path_id, position) SELECT (SELECT id FROM main.lens WHERE name = (SELECT name FROM old.lens WHERE id = old.lens_path.lens_id)), (SELECT id FROM main.path WHERE path = (SELECT path FROM old.path WHERE id = old.lens_path.path_id)), position FROM old.lens_path WHERE (SELECT id FROM main.lens WHERE name = (SELECT name FROM old.lens WHERE id = old.lens_path.lens_id)) IS NOT NULL AND (SELECT id FROM main.path WHERE path = (SELECT path FROM old.path WHERE id = old.lens_path.path_id)) IS NOT NULL";

    /// (path, `zahir_json`) for paths that have non-empty `zahir_json`. Used for prior-zahir reuse.
    pub const SELECT_SNAPSHOT_PATH_ZAHIR_JSON: &'static str =
        "SELECT path, zahir_json FROM snapshot WHERE zahir_json IS NOT NULL AND zahir_json != ''";

    /// (path, category) for all snapshot rows. Used to preserve categories across Zahir enrichment.
    pub const SELECT_SNAPSHOT_PATH_CATEGORY: &'static str =
        "SELECT path, category FROM snapshot WHERE path IS NOT NULL";

    /// Distinct categories for TUI left bar.
    pub const SELECT_SNAPSHOT_CATEGORIES: &'static str = "SELECT DISTINCT category FROM snapshot WHERE category IS NOT NULL AND category != '' ORDER BY category";

    /// Distinct `created_ns` from `delta_log`, newest first (for Delta mode).
    pub const SELECT_DELTA_LOG_SNAPSHOT_TIMESTAMPS: &'static str =
        "SELECT DISTINCT created_ns FROM delta_log ORDER BY created_ns DESC";

    /// (`created_ns`, path) for a given `delta_type`, newest first then path.
    pub const SELECT_DELTA_LOG_ROWS_BY_TYPE: &'static str = "SELECT created_ns, path FROM delta_log WHERE delta_type = ?1 ORDER BY created_ns DESC, path";

    /// (path, category, size) for TUI list; `zahir_json` loaded on demand for selected row.
    pub const SELECT_SNAPSHOT_ROWS_FOR_TUI_BY_CATEGORY: &'static str =
        "SELECT path, category, size FROM snapshot WHERE category = ?1 ORDER BY path";

    /// (path, category, size) for TUI list; `zahir_json` loaded on demand for selected row.
    pub const SELECT_SNAPSHOT_ROWS_FOR_TUI_ALL: &'static str =
        "SELECT path, category, size FROM snapshot ORDER BY category, path";

    /// Single pass for TUI cold start: prior Nefax columns + category (no `zahir_json`). Order matches list query.
    pub const SELECT_SNAPSHOT_TUI_START: &'static str =
        "SELECT path, mtime_ns, size, hash, category FROM snapshot ORDER BY category, path";

    /// `zahir_json` for a single path (for right-pane on-demand load).
    pub const SELECT_SNAPSHOT_ZAHIR_JSON_BY_PATH: &'static str =
        "SELECT zahir_json FROM snapshot WHERE path = ?1";

    /// `mtime_ns` for a single path (for viewer footer last-modified).
    pub const SELECT_SNAPSHOT_MTIME_BY_PATH: &'static str =
        "SELECT mtime_ns FROM snapshot WHERE path = ?1";

    /// (path, `mtime_ns`) for all snapshot rows (used by Mod sort in middle pane).
    pub const SELECT_SNAPSHOT_PATH_MTIME_ALL: &'static str = "SELECT path, mtime_ns FROM snapshot";

    /// (path, size, hash) for non-directory rows; used for duplicate detection (by hash or content).
    pub const SELECT_SNAPSHOT_PATH_SIZE_HASH: &'static str =
        "SELECT path, size, hash FROM snapshot WHERE category IS NULL OR category != 'Directory'";

    /// Lens table: list lens names for TUI.
    pub const SELECT_LENS_NAMES: &'static str = "SELECT name FROM lens ORDER BY id";

    /// Lens id by name (for loading paths).
    pub const SELECT_LENS_ID_BY_NAME: &'static str = "SELECT id FROM lens WHERE name = ?1";

    /// Path id by path string (for `lens_path`).
    pub const SELECT_PATH_ID_BY_PATH: &'static str = "SELECT id FROM path WHERE path = ?1";

    /// Insert path, return id (use INSERT OR IGNORE then SELECT id).
    pub const INSERT_PATH: &'static str = "INSERT OR IGNORE INTO path (path) VALUES (?1)";

    /// Rename a normalized path string (lens `path` table); `lens_path` rows follow `path_id`.
    pub const UPDATE_PATH_STRING: &'static str = "UPDATE path SET path = ?1 WHERE path = ?2";

    /// Remove a path row (`lens_path` rows CASCADE). Call after deleting/moving the file on disk.
    pub const DELETE_PATH_ROW: &'static str = "DELETE FROM path WHERE path = ?1";

    pub const INSERT_LENS: &'static str = "INSERT INTO lens (name) VALUES (?1)";

    /// (`path_id`, position) for a lens, ordered by position. Join with path to get path string.
    pub const SELECT_LENS_PATH_IDS: &'static str =
        "SELECT path_id, position FROM lens_path WHERE lens_id = ?1 ORDER BY position";

    pub const INSERT_LENS_PATH: &'static str =
        "INSERT OR REPLACE INTO lens_path (lens_id, path_id, position) VALUES (?1, ?2, ?3)";

    /// Remove one path from a lens (by lens name and path string).
    pub const DELETE_LENS_PATH_ROW: &'static str = "DELETE FROM lens_path WHERE lens_id = (SELECT id FROM lens WHERE name = ?1) AND path_id = (SELECT id FROM path WHERE path = ?2)";
    /// Rename a lens.
    pub const UPDATE_LENS_NAME: &'static str = "UPDATE lens SET name = ?2 WHERE name = ?1";
    /// Delete a lens (`lens_path` rows removed by FK CASCADE).
    pub const DELETE_LENS: &'static str = "DELETE FROM lens WHERE name = ?1";

    /// (path, category, size) for TUI list for a lens; joins `lens_path`, path, and snapshot for category/size.
    pub const SELECT_LENS_ROWS_FOR_TUI: &'static str = "
        SELECT p.path, COALESCE(s.category, ''), COALESCE(s.size, 0)
        FROM lens_path lp
        JOIN path p ON lp.path_id = p.id
        LEFT JOIN snapshot s ON s.path = p.path
        WHERE lp.lens_id = ?1
        ORDER BY lp.position";

    #[must_use]
    pub fn create_query_for_nefax_from_db(table_name: &str) -> String {
        format!("SELECT path, mtime_ns, size, hash FROM {table_name}")
    }

    #[must_use]
    pub fn create_query_for_settings_from_db() -> String {
        "SELECT num_threads, drive_type, parallel_walk, config_source FROM settings WHERE id = 1"
            .to_string()
    }
}

/// Delta type for the `delta_log` table. Order (0 = Added, 1 = Mod, 2 = Removed) is used for TUI category index.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeltaType {
    Added,
    Mod,
    Removed,
}

/// Number of delta categories (Added, Mod, Removed). Use for TUI category list length.
pub const DELTA_CATEGORY_COUNT: usize = 3;

impl DeltaType {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            DeltaType::Added => "added",
            DeltaType::Mod => "mod",
            DeltaType::Removed => "removed",
        }
    }

    /// Index for TUI left-pane category (0 = Added, 1 = Mod, 2 = Removed).
    #[allow(dead_code)]
    #[must_use]
    pub const fn as_index(self) -> usize {
        match self {
            DeltaType::Added => 0,
            DeltaType::Mod => 1,
            DeltaType::Removed => 2,
        }
    }

    /// Delta type for the given TUI category index. Out-of-range maps to Removed.
    #[must_use]
    pub const fn from_index(idx: usize) -> Self {
        match idx {
            0 => DeltaType::Added,
            1 => DeltaType::Mod,
            _ => DeltaType::Removed,
        }
    }

    pub fn iter() -> impl Iterator<Item = DeltaType> {
        [DeltaType::Added, DeltaType::Mod, DeltaType::Removed].into_iter()
    }
}

/// Category for ublx db: ublx-defined variants plus all [`ZahirFT`] (zahirscan) via [`UblxDbCategory::Zahir`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UblxDbCategory {
    UblxLog,
    Git,
    // Hidden,
    Directory,
    File,
    /// All zahirscan file types; use [`ZahirFT::as_metadata_name`] for the display string.
    Zahir(ZahirFT),
}

impl UblxDbCategory {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            UblxDbCategory::UblxLog => "UBLX Log",
            UblxDbCategory::Git => "Git",
            // UblxDbCategory::Hidden => "Hidden",
            UblxDbCategory::Directory => "Directory",
            UblxDbCategory::File => "File",
            UblxDbCategory::Zahir(ft) => ft.as_metadata_name(),
        }
    }

    /// Parse a snapshot row `category` column (strings written by [`UblxDbCategory::get_category_for_path`]).
    #[must_use]
    pub fn from_snapshot_category(s: &str) -> Self {
        let t = s.trim();
        if t.is_empty() {
            return Self::File;
        }
        if t == Self::UblxLog.as_str() {
            return Self::UblxLog;
        }
        if t == Self::Git.as_str() {
            return Self::Git;
        }
        if t == Self::Directory.as_str() {
            return Self::Directory;
        }
        if t == Self::File.as_str() {
            return Self::File;
        }
        if let Some(ft) = file_type_from_metadata_name(t) {
            return Self::Zahir(ft);
        }
        Self::File
    }

    /// Get the category for a given path.
    #[must_use]
    pub fn get_category_for_path(
        path_ref: &Path,
        ublx_paths: Option<&UblxPaths>,
        zahir_file_type: Option<&str>,
    ) -> String {
        // Check for ublx.log
        if ublx_paths.is_some_and(|p| p.log_path() == path_ref) {
            return UblxDbCategory::UblxLog.as_str().to_string();
        }
        // Check for .git
        if Self::is_git_path(path_ref) {
            return UblxDbCategory::Git.as_str().to_string();
        }
        // Check for hidden files
        // if Self::is_hidden_path(path_ref) {
        //     return UblxDbCategory::Hidden.as_str().to_string();
        // }
        // Directories before path-hint (extension/linguist can misclassify e.g. `Vol.1`).
        if Self::is_directory_path(path_ref) {
            return UblxDbCategory::Directory.as_str().to_string();
        }
        Self::get_zahir_file_type_or_fallback(zahir_file_type)
    }

    fn get_zahir_file_type_or_fallback(zahir_file_type: Option<&str>) -> String {
        let fallback = UblxDbCategory::File.as_str().to_string();
        let s = match zahir_file_type {
            None => return fallback,
            Some(s) => s.trim(),
        };
        if s.is_empty() || s.eq_ignore_ascii_case("Unknown") {
            return fallback;
        }
        if let Some(ft) = file_type_from_metadata_name(s) {
            return UblxDbCategory::Zahir(ft).as_str().to_string();
        }
        // Non-metadata label (future zahir strings or legacy rows): keep as stored.
        s.to_string()
    }

    #[inline]
    #[allow(dead_code)]
    fn is_hidden_path(path_ref: &Path) -> bool {
        path_ref
            .file_name()
            .and_then(|n| n.to_str())
            .is_some_and(|n| n.starts_with('.'))
    }

    #[inline]
    fn is_git_path(path_ref: &Path) -> bool {
        path_ref.to_string_lossy().contains(".git")
    }

    #[inline]
    fn is_directory_path(path_ref: &Path) -> bool {
        fs::metadata(path_ref).map(|m| m.is_dir()).unwrap_or(false)
    }
}