Skip to main content

zsh/compsys/
cache.rs

1//! SQLite mirror tables for compsys.
2//!
3//! NOT the completion cache. The authoritative completion / autoload
4//! cache is the rkyv-mmap'd shard set at `~/.zshrs/*.rkyv`
5//! (zero-copy hot path; see `src/extensions/autoload_cache.rs` and
6//! `src/compsys/README.md`). This SQLite file is a read-only mirror
7//! hydrated alongside the shards for `dbview` / SQL inspection only
8//! — the Tab cache hit/miss path never opens this connection.
9//!
10//! Mirror-side optimizations (still in place because `dbview` queries
11//! benefit from them):
12//! - FTS5 vtables sit beside the flat mirror tables for ad-hoc SQL
13//!   prefix search from `dbview`; not consulted by the completion hot
14//!   path
15//! - WAL mode for concurrent reads
16//! - Memory-mapped I/O (mmap)
17//! - No JOINs, no GROUP BY, no subqueries
18//! - Denormalized flat tables with covering indexes
19//! - Prepared statement caching
20
21use rusqlite::{params, Connection, OptionalExtension};
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24
25/// SQLite cache for completion system
26pub struct CompsysCache {
27    /// `conn` field.
28    conn: Connection,
29}
30
31/// Returns the default cache path: `$ZSHRS_HOME/compsys.db` (default
32/// `~/.zshrs/compsys.db`). Project policy forbids `~/.cache/zshrs/`
33/// and `~/Library/Caches/zshrs/`.
34pub fn default_cache_path() -> PathBuf {
35    let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
36        PathBuf::from(custom)
37    } else {
38        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
39        PathBuf::from(home).join(".zshrs")
40    };
41    root.join("compsys.db")
42}
43
44/// Advisory-lock file guarding a whole `compsys.db` rebuild:
45/// `$ZSHRS_HOME/compsys.db.lock`.
46///
47/// Separate from the database file because the rebuild replaces that
48/// file by `rename(2)`; a lock taken on the db inode would be dropped
49/// on the floor the moment the new inode landed.
50pub fn cache_lock_path() -> PathBuf {
51    let db = default_cache_path();
52    db.with_file_name(format!(
53        "{}.lock",
54        db.file_name()
55            .map(|s| s.to_string_lossy().into_owned())
56            .unwrap_or_else(|| "compsys.db".to_string())
57    ))
58}
59
60/// Take the blocking exclusive `flock` that serialises rebuilds of
61/// `compsys.db`, held across the validity re-check, the build and the
62/// `rename`.
63///
64/// The rebuild is a whole-database build-aside plus `rename`, so two
65/// shells rebuilding at once each install a complete database over the
66/// other's — whichever renames last wins and the loser's entire scan is
67/// discarded. Sixteen of the user's shells share this one file, so that
68/// is the ordinary case, not a corner. Same shape as
69/// `script_cache::acquire_lock` and `autoload_cache::acquire_lock`:
70/// exclusive, blocking, on a side file that no rename replaces.
71pub fn acquire_rebuild_lock() -> Option<nix::fcntl::Flock<std::fs::File>> {
72    let path = cache_lock_path();
73    if let Some(parent) = path.parent() {
74        let _ = std::fs::create_dir_all(parent);
75    }
76    let f = std::fs::File::options()
77        .read(true)
78        .write(true)
79        .create(true)
80        .truncate(false)
81        .open(&path)
82        .ok()?;
83    nix::fcntl::Flock::lock(f, nix::fcntl::FlockArg::LockExclusive).ok()
84}
85
86/// `(mtime_secs, mtime_nsecs, len)` of the running `zshrs` binary, in
87/// the exact form stamped into a built cache.
88///
89/// All three terms are load-bearing. `mtime` alone is whole seconds in
90/// the form most stat wrappers report, so a rebuild landing in the same
91/// second as the stamp compares equal; the nanoseconds field separates
92/// them. Length alone does not identify a build either &mdash; two debug
93/// binaries here measured byte-identical in size. And the comparison is
94/// EQUALITY, never `<`/`>=`: a binary whose mtime moves backwards (an
95/// older build restored over a newer one, a `cp -p`, a checkout of a
96/// previously built `target/`) passes any "not older than" test while
97/// being a different build, which is the same bug `script_cache` and
98/// `plugin_cache` carried until `autoload_cache`'s exact-equality shape
99/// was brought to them.
100pub fn current_binary_identity() -> Option<(i64, i64, u64)> {
101    use std::os::unix::fs::MetadataExt;
102    static BIN_ID: std::sync::OnceLock<Option<(i64, i64, u64)>> = std::sync::OnceLock::new();
103    *BIN_ID.get_or_init(|| {
104        let exe = std::env::current_exe().ok()?;
105        let meta = std::fs::metadata(&exe).ok()?;
106        Some((meta.mtime(), meta.mtime_nsec(), meta.len()))
107    })
108}
109
110/// The stamped form of `current_binary_identity`, or `None` when
111/// `current_exe()` cannot be read &mdash; in which case nothing can be
112/// proven about who built a cache, and every caller treats that as a
113/// miss rather than an unconditional accept.
114pub fn binary_identity_stamp() -> Option<String> {
115    current_binary_identity().map(|(secs, nsecs, len)| format!("{}.{}.{}", secs, nsecs, len))
116}
117
118impl CompsysCache {
119    /// Access the underlying SQLite connection (for dbview etc.)
120    pub fn conn(&self) -> &Connection {
121        &self.conn
122    }
123
124    /// Count rows in a table.
125    pub fn count_table(&self, table: &str) -> rusqlite::Result<usize> {
126        // Table name is not user input — it comes from our code.
127        let sql = format!("SELECT COUNT(*) FROM {}", table);
128        self.conn
129            .query_row(&sql, [], |row| row.get::<_, i64>(0).map(|n| n as usize))
130    }
131
132    /// Count rows matching a WHERE clause.
133    pub fn count_table_where(&self, table: &str, condition: &str) -> rusqlite::Result<usize> {
134        let sql = format!("SELECT COUNT(*) FROM {} WHERE {}", table, condition);
135        self.conn
136            .query_row(&sql, [], |row| row.get::<_, i64>(0).map(|n| n as usize))
137    }
138
139    /// Open or create cache database with maximum performance settings
140    pub fn open(path: impl AsRef<Path>) -> rusqlite::Result<Self> {
141        // Hold the script's fd range while sqlite opens the db (and, via the
142        // pragmas below, its WAL and SHM side files) so none of them land on
143        // fds 3-9. See crate::lowfd.
144        let _lowfd = crate::lowfd::LowFdGuard::new();
145        let conn = Connection::open(path)?;
146        crate::lowfd::register_internal_fds(); // c:Src/utils.c:2009
147        let cache = Self { conn };
148        cache.configure_for_speed()?;
149        cache.init_schema()?;
150        Ok(cache)
151    }
152
153    /// In-memory cache (for testing)
154    pub fn memory() -> rusqlite::Result<Self> {
155        let conn = Connection::open_in_memory()?;
156        let cache = Self { conn };
157        cache.configure_for_speed()?;
158        cache.init_schema()?;
159        Ok(cache)
160    }
161
162    /// Configure SQLite for maximum read performance (called on every open)
163    fn configure_for_speed(&self) -> rusqlite::Result<()> {
164        // WAL mode persists, but cache/mmap need to be set each session
165        self.conn.execute_batch(
166            r#"
167            PRAGMA journal_mode = WAL;
168            PRAGMA synchronous = NORMAL;
169            PRAGMA cache_size = -64000;
170            PRAGMA mmap_size = 268435456;
171            PRAGMA temp_store = MEMORY;
172            "#,
173        )
174    }
175
176    fn init_schema(&self) -> rusqlite::Result<()> {
177        // Migration: drop the legacy `bytecode BLOB` column from `autoloads`
178        // if and only if the table already exists with that schema. Bytecode
179        // now lives in the rkyv shard at ~/.zshrs/autoloads.rkyv (see
180        // `crate::autoload_cache`); SQLite holds only the body/source
181        // metadata. The user's directive: "delete all sqlite columns related
182        // to bytecode, sqlite3 is read only mirror".
183        //
184        // SQLite ≥3.35 supports `ALTER TABLE … DROP COLUMN`. Older runtimes
185        // need the recreate-table dance. We use the recreate path here for
186        // portability and gate it on a table-existence + column-existence
187        // probe so a fresh DB never tries to SELECT from a missing
188        // `autoloads`. Cost: one full table rewrite the first time a
189        // post-migration zshrs opens an old DB; bodies/sources/offsets/sizes
190        // are preserved.
191        let has_legacy_bytecode_col = {
192            let exists: i64 = self.conn.query_row(
193                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='autoloads'",
194                [],
195                |row| row.get(0),
196            )?;
197            if exists == 0 {
198                false
199            } else {
200                let mut stmt = self.conn.prepare("PRAGMA table_info(autoloads)")?;
201                let cols: Vec<String> = stmt
202                    .query_map([], |row| row.get::<_, String>(1))?
203                    .collect::<rusqlite::Result<_>>()?;
204                cols.iter().any(|c| c == "bytecode")
205            }
206        };
207        if has_legacy_bytecode_col {
208            self.conn.execute_batch(
209                r#"
210                BEGIN;
211                CREATE TABLE autoloads_new (
212                    name TEXT PRIMARY KEY,
213                    source TEXT NOT NULL,
214                    offset INTEGER NOT NULL,
215                    size INTEGER NOT NULL,
216                    body TEXT
217                ) WITHOUT ROWID;
218                INSERT OR IGNORE INTO autoloads_new (name, source, offset, size, body)
219                    SELECT name, source, offset, size, body FROM autoloads;
220                DROP TABLE autoloads;
221                ALTER TABLE autoloads_new RENAME TO autoloads;
222                COMMIT;
223                "#,
224            )?;
225        }
226        self.conn.execute_batch(
227            r#"
228            -- Autoloads: flat table, PRIMARY KEY = clustered index
229            -- body stores actual function definition - NO filesystem access on autoload -Xz
230            -- compinit reads from .zwc or plain files ONCE, stores body here.
231            -- bytecode is held separately in the rkyv autoload-cache shard.
232            CREATE TABLE IF NOT EXISTS autoloads (
233                name TEXT PRIMARY KEY,
234                source TEXT NOT NULL,
235                offset INTEGER NOT NULL,
236                size INTEGER NOT NULL,
237                body TEXT
238            ) WITHOUT ROWID;
239
240            -- zstyle: flat lookup by pattern+style
241            CREATE TABLE IF NOT EXISTS zstyles (
242                pattern TEXT NOT NULL,
243                style TEXT NOT NULL,
244                value TEXT NOT NULL,
245                eval INTEGER DEFAULT 0,
246                PRIMARY KEY (pattern, style)
247            ) WITHOUT ROWID;
248
249            -- Completion mappings: direct key lookup
250            CREATE TABLE IF NOT EXISTS comps (
251                command TEXT PRIMARY KEY,
252                function TEXT NOT NULL
253            ) WITHOUT ROWID;
254
255            -- Pattern completions (`#compdef -p pat`, compinit sh:399 ->
256            -- `_patcomps`). Consulted BEFORE the `$_comps` name lookup.
257            CREATE TABLE IF NOT EXISTS patcomps (
258                pattern TEXT PRIMARY KEY,
259                function TEXT NOT NULL
260            ) WITHOUT ROWID;
261
262            -- Post-pattern completions (`#compdef -P pat`, compinit sh:404 ->
263            -- `_postpatcomps`). A SEPARATE table on purpose: `_dispatch`
264            -- walks `_patcomps` before the `$_comps` lookup and
265            -- `_postpatcomps` after it, and the post pass sets
266            -- `_compskip=default` first (_dispatch sh:72) so the sh:84
267            -- default fallback is suppressed. Storing both in `patcomps`
268            -- (the pre-fix behaviour) ran every `-P` completer in the PRE
269            -- phase without that flag, so `PATH=/usr/bin:<TAB>` ran
270            -- `_dir_list` AND then fell through to `_value`/`_default`,
271            -- listing every file instead of only directories.
272            CREATE TABLE IF NOT EXISTS postpatcomps (
273                pattern TEXT PRIMARY KEY,
274                function TEXT NOT NULL
275            ) WITHOUT ROWID;
276
277            -- Key completions
278            CREATE TABLE IF NOT EXISTS keycomps (
279                key TEXT PRIMARY KEY,
280                function TEXT NOT NULL
281            ) WITHOUT ROWID;
282
283            -- Services
284            CREATE TABLE IF NOT EXISTS services (
285                command TEXT PRIMARY KEY,
286                service TEXT NOT NULL
287            ) WITHOUT ROWID;
288
289            -- Result cache
290            CREATE TABLE IF NOT EXISTS cache (
291                context TEXT PRIMARY KEY,
292                data BLOB NOT NULL,
293                mtime INTEGER NOT NULL
294            ) WITHOUT ROWID;
295
296            -- PATH executables: flat, fast prefix via FTS5
297            CREATE TABLE IF NOT EXISTS executables (
298                name TEXT PRIMARY KEY,
299                path TEXT NOT NULL
300            ) WITHOUT ROWID;
301
302            -- Named directories
303            CREATE TABLE IF NOT EXISTS named_dirs (
304                name TEXT PRIMARY KEY,
305                path TEXT NOT NULL
306            ) WITHOUT ROWID;
307
308            -- Shell functions
309            CREATE TABLE IF NOT EXISTS shell_functions (
310                name TEXT PRIMARY KEY,
311                source TEXT NOT NULL
312            ) WITHOUT ROWID;
313
314            -- Metadata
315            CREATE TABLE IF NOT EXISTS metadata (
316                key TEXT PRIMARY KEY,
317                value TEXT NOT NULL
318            ) WITHOUT ROWID;
319
320            -- FTS5 for lightning-fast prefix search (standalone, not content-synced)
321            CREATE VIRTUAL TABLE IF NOT EXISTS fts_comps USING fts5(
322                command,
323                tokenize='unicode61'
324            );
325
326            CREATE VIRTUAL TABLE IF NOT EXISTS fts_executables USING fts5(
327                name,
328                tokenize='unicode61'
329            );
330
331            CREATE VIRTUAL TABLE IF NOT EXISTS fts_shell_functions USING fts5(
332                name,
333                tokenize='unicode61'
334            );
335
336            -- Covering index for comps prefix search (fallback if FTS unavailable)
337            CREATE INDEX IF NOT EXISTS idx_comps_cmd ON comps(command);
338            CREATE INDEX IF NOT EXISTS idx_comps_func ON comps(function);
339            CREATE INDEX IF NOT EXISTS idx_executables_name ON executables(name);
340            CREATE INDEX IF NOT EXISTS idx_shell_functions_name ON shell_functions(name);
341            CREATE INDEX IF NOT EXISTS idx_named_dirs_name ON named_dirs(name);
342        "#,
343        )?;
344        self.migrate()?;
345        Ok(())
346    }
347
348    /// Schema migrations for existing databases.
349    ///
350    /// The legacy `bytecode BLOB` re-add path was removed when bytecode moved
351    /// to the rkyv shard (~/.zshrs/autoloads.rkyv). The pre-v0.8.16
352    /// `ast` column is still detected here and dropped — its data was the
353    /// same kind of bytecode and is now obsolete.
354    fn migrate(&self) -> rusqlite::Result<()> {
355        let has_ast: bool = self
356            .conn
357            .prepare("SELECT ast FROM autoloads LIMIT 0")
358            .is_ok();
359        if has_ast {
360            // Recreate-table dance to drop the `ast` column.
361            self.conn.execute_batch(
362                r#"
363                BEGIN;
364                CREATE TABLE autoloads_no_ast (
365                    name TEXT PRIMARY KEY,
366                    source TEXT NOT NULL,
367                    offset INTEGER NOT NULL,
368                    size INTEGER NOT NULL,
369                    body TEXT
370                ) WITHOUT ROWID;
371                INSERT OR IGNORE INTO autoloads_no_ast (name, source, offset, size, body)
372                    SELECT name, source, offset, size, body FROM autoloads;
373                DROP TABLE autoloads;
374                ALTER TABLE autoloads_no_ast RENAME TO autoloads;
375                COMMIT;
376                "#,
377            )?;
378        }
379        self.migrate_completion_tables()?;
380        Ok(())
381    }
382
383    /// Schema generation for the completion-mapping tables (`comps`,
384    /// `services`, `patcomps`, `postpatcomps`). Bump whenever a change makes
385    /// previously-written rows un-interpretable, and old caches rebuild
386    /// silently on next `compinit`.
387    ///
388    /// 2 — `postpatcomps` split out of `patcomps`. Before this, `#compdef -P`
389    ///     patterns were written into `patcomps` ("For now, we'll merge them
390    ///     into patcomps"), so a generation-1 cache cannot say which of its
391    ///     `patcomps` rows are really post-patterns.
392    const COMPLETION_SCHEMA_GENERATION: &'static str = "2";
393
394    /// Drop the completion mappings when they were written by an older,
395    /// incompatible generation. `comps` going empty makes
396    /// `compinit::cache_is_valid` false, so the next `compinit` re-scans
397    /// `$fpath` and repopulates every table — no user-visible step.
398    fn migrate_completion_tables(&self) -> rusqlite::Result<()> {
399        let current: Option<String> = self
400            .conn
401            .query_row(
402                "SELECT value FROM metadata WHERE key = 'completion_schema'",
403                [],
404                |row| row.get(0),
405            )
406            .ok();
407        if current.as_deref() == Some(Self::COMPLETION_SCHEMA_GENERATION) {
408            return Ok(());
409        }
410        let stale: i64 = self
411            .conn
412            .query_row("SELECT COUNT(*) FROM comps", [], |row| row.get(0))
413            .unwrap_or(0);
414        if stale > 0 {
415            tracing::info!(
416                rows = stale,
417                from = current.as_deref().unwrap_or("1"),
418                to = Self::COMPLETION_SCHEMA_GENERATION,
419                "compsys cache: completion tables from an older generation, rebuilding"
420            );
421        }
422        self.conn.execute_batch(
423            r#"
424            DELETE FROM comps;
425            DELETE FROM services;
426            DELETE FROM patcomps;
427            DELETE FROM postpatcomps;
428            DELETE FROM fts_comps;
429            "#,
430        )?;
431        self.conn.execute(
432            "INSERT OR REPLACE INTO metadata (key, value) VALUES ('completion_schema', ?1)",
433            params![Self::COMPLETION_SCHEMA_GENERATION],
434        )?;
435        Ok(())
436    }
437
438    // =========================================================================
439    // Autoloads - function stubs
440    // =========================================================================
441
442    /// Register an autoload stub (without body)
443    pub fn add_autoload(
444        &self,
445        name: &str,
446        source: &str,
447        offset: i64,
448        size: i64,
449    ) -> rusqlite::Result<()> {
450        self.conn.execute(
451            "INSERT OR REPLACE INTO autoloads (name, source, offset, size, body) VALUES (?1, ?2, ?3, ?4, NULL)",
452            params![name, source, offset, size],
453        )?;
454        Ok(())
455    }
456
457    /// Register an autoload with full function body (for instant loading)
458    pub fn add_autoload_with_body(
459        &self,
460        name: &str,
461        source: &str,
462        body: &str,
463    ) -> rusqlite::Result<()> {
464        self.conn.execute(
465            "INSERT OR REPLACE INTO autoloads (name, source, offset, size, body) VALUES (?1, ?2, 0, ?3, ?4)",
466            params![name, source, body.len() as i64, body],
467        )?;
468        Ok(())
469    }
470
471    /// Bulk insert autoloads (much faster)
472    pub fn add_autoloads_bulk(
473        &mut self,
474        autoloads: &[(String, String, i64, i64)],
475    ) -> rusqlite::Result<()> {
476        let tx = self.conn.transaction()?;
477        {
478            let mut stmt = tx.prepare(
479                "INSERT OR REPLACE INTO autoloads (name, source, offset, size, body) VALUES (?1, ?2, ?3, ?4, NULL)"
480            )?;
481            for (name, source, offset, size) in autoloads {
482                stmt.execute(params![name, source, offset, size])?;
483            }
484        }
485        tx.commit()?;
486        Ok(())
487    }
488
489    /// Bulk insert autoloads with bodies (for compinit to cache function definitions)
490    pub fn add_autoloads_with_bodies_bulk(
491        &mut self,
492        autoloads: &[(String, String, String)], // (name, source, body)
493    ) -> rusqlite::Result<()> {
494        let tx = self.conn.transaction()?;
495        {
496            let mut stmt = tx.prepare(
497                "INSERT OR REPLACE INTO autoloads (name, source, offset, size, body) VALUES (?1, ?2, 0, ?3, ?4)"
498            )?;
499            for (name, source, body) in autoloads {
500                stmt.execute(params![name, source, body.len() as i64, body])?;
501            }
502        }
503        tx.commit()?;
504        Ok(())
505    }
506
507    /// Lookup autoload by name
508    pub fn get_autoload(&self, name: &str) -> rusqlite::Result<Option<AutoloadStub>> {
509        self.conn
510            .query_row(
511                "SELECT source, offset, size, body FROM autoloads WHERE name = ?1",
512                params![name],
513                |row| {
514                    Ok(AutoloadStub {
515                        name: name.to_string(),
516                        source: row.get(0)?,
517                        offset: row.get(1)?,
518                        size: row.get(2)?,
519                        body: row.get(3)?,
520                    })
521                },
522            )
523            .optional()
524    }
525
526    /// Get function body directly (fast path for autoload -Xz)
527    pub fn get_autoload_body(&self, name: &str) -> rusqlite::Result<Option<String>> {
528        self.conn
529            .query_row(
530                "SELECT body FROM autoloads WHERE name = ?1",
531                params![name],
532                |row| row.get(0),
533            )
534            .optional()
535    }
536
537    /// Count autoloads with a non-NULL body. Replaces the legacy
538    /// `count_autoloads_missing_bytecode` — bytecode coverage is now derived
539    /// by subtracting the rkyv shard's `cached_names` set from this count
540    /// (caller-side, see the `autoload_cache` module in the `zsh` / zshrs library crate).
541    pub fn count_autoloads_with_body(&self) -> rusqlite::Result<usize> {
542        self.conn.query_row(
543            "SELECT COUNT(*) FROM autoloads WHERE body IS NOT NULL",
544            [],
545            |row| row.get::<_, i64>(0).map(|n| n as usize),
546        )
547    }
548
549    /// Get a batch of `(name, body)` pairs for autoloads with a non-NULL
550    /// body, excluding any whose name is in `exclude` (e.g. names already
551    /// present in the rkyv autoload-bytecode shard). Used by compinit's
552    /// background backfill: SQLite supplies the source bodies, the caller
553    /// parses+compiles, then writes results to the rkyv shard.
554    ///
555    /// Returns up to `limit` entries; caller iterates until the empty vec
556    /// or counts what was returned.
557    pub fn get_autoload_bodies_excluding(
558        &self,
559        exclude: &std::collections::HashSet<String>,
560        limit: usize,
561    ) -> rusqlite::Result<Vec<(String, String)>> {
562        let mut stmt = self
563            .conn
564            .prepare("SELECT name, body FROM autoloads WHERE body IS NOT NULL ORDER BY name")?;
565        let rows = stmt.query_map([], |row| {
566            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
567        })?;
568        let mut out = Vec::with_capacity(limit.min(256));
569        for row in rows {
570            let (name, body) = row?;
571            if exclude.contains(&name) {
572                continue;
573            }
574            out.push((name, body));
575            if out.len() >= limit {
576                break;
577            }
578        }
579        Ok(out)
580    }
581
582    /// Get function body with ZWC fallback
583    /// 1. If body column has content, return it (fast path)
584    /// 2. If body is NULL but source/offset/size exist, read from ZWC file
585    /// 3. Returns None if function not found or ZWC read fails
586    pub fn get_autoload_body_or_zwc(&self, name: &str) -> Option<String> {
587        let stub = self.get_autoload(name).ok()??;
588
589        // Fast path: body is cached
590        if let Some(body) = stub.body {
591            return Some(body);
592        }
593
594        // Fallback: read from ZWC file
595        if stub.size > 0 && !stub.source.is_empty() {
596            return Self::read_function_from_zwc(&stub.source, stub.offset, stub.size);
597        }
598
599        None
600    }
601
602    /// Read function body from ZWC file at given offset/size
603    fn read_function_from_zwc(zwc_path: &str, offset: i64, size: i64) -> Option<String> {
604        use std::io::{Read, Seek, SeekFrom};
605
606        let mut file = std::fs::File::open(zwc_path).ok()?;
607        file.seek(SeekFrom::Start(offset as u64)).ok()?;
608
609        let mut buf = vec![0u8; size as usize];
610        file.read_exact(&mut buf).ok()?;
611
612        // ZWC stores tokenized strings - need to untokenize
613        // For now, just try to interpret as UTF-8 (works for most cases)
614        // TODO: proper untokenization like zwc.rs does
615        match String::from_utf8(buf) {
616            Ok(s) => Some(s),
617            Err(e) => Some(String::from_utf8_lossy(e.as_bytes()).into_owned()),
618        }
619    }
620
621    /// Count autoloads
622    pub fn autoload_count(&self) -> rusqlite::Result<i64> {
623        self.conn
624            .query_row("SELECT COUNT(*) FROM autoloads", [], |row| row.get(0))
625    }
626
627    /// List all autoload names (for debugging)
628    pub fn list_autoloads(&self, limit: usize) -> rusqlite::Result<Vec<String>> {
629        let mut stmt = self.conn.prepare("SELECT name FROM autoloads LIMIT ?1")?;
630        let rows = stmt.query_map(params![limit as i64], |row| row.get(0))?;
631        rows.collect()
632    }
633
634    /// List all autoload names (no limit)
635    pub fn list_autoload_names(&self) -> rusqlite::Result<Vec<String>> {
636        let mut stmt = self.conn.prepare("SELECT name FROM autoloads")?;
637        let rows = stmt.query_map([], |row| row.get(0))?;
638        rows.collect()
639    }
640
641    // =========================================================================
642    // zstyle database
643    // =========================================================================
644
645    /// Set a zstyle
646    pub fn set_zstyle(
647        &self,
648        pattern: &str,
649        style: &str,
650        values: &[String],
651        eval: bool,
652    ) -> rusqlite::Result<()> {
653        let value_json = serde_values_to_json(values);
654        self.conn.execute(
655            "INSERT OR REPLACE INTO zstyles (pattern, style, value, eval) VALUES (?1, ?2, ?3, ?4)",
656            params![pattern, style, value_json, eval as i32],
657        )?;
658        Ok(())
659    }
660
661    /// Bulk insert zstyles
662    pub fn set_zstyles_bulk(
663        &mut self,
664        styles: &[(String, String, Vec<String>, bool)],
665    ) -> rusqlite::Result<()> {
666        let tx = self.conn.transaction()?;
667        {
668            let mut stmt = tx.prepare(
669                "INSERT OR REPLACE INTO zstyles (pattern, style, value, eval) VALUES (?1, ?2, ?3, ?4)"
670            )?;
671            for (pattern, style, values, eval) in styles {
672                let value_json = serde_values_to_json(values);
673                stmt.execute(params![pattern, style, value_json, *eval as i32])?;
674            }
675        }
676        tx.commit()?;
677        Ok(())
678    }
679
680    /// Delete a zstyle
681    pub fn delete_zstyle(&self, pattern: &str, style: Option<&str>) -> rusqlite::Result<usize> {
682        if let Some(s) = style {
683            self.conn.execute(
684                "DELETE FROM zstyles WHERE pattern = ?1 AND style = ?2",
685                params![pattern, s],
686            )
687        } else {
688            self.conn
689                .execute("DELETE FROM zstyles WHERE pattern = ?1", params![pattern])
690        }
691    }
692
693    /// Lookup zstyle - returns all matching patterns sorted by specificity
694    pub fn lookup_zstyle(
695        &self,
696        context: &str,
697        style: &str,
698    ) -> rusqlite::Result<Option<ZStyleEntry>> {
699        let mut stmt = self
700            .conn
701            .prepare("SELECT pattern, value, eval FROM zstyles WHERE style = ?1")?;
702
703        let entries: Vec<(String, String, bool)> = stmt
704            .query_map(params![style], |row| {
705                Ok((row.get(0)?, row.get(1)?, row.get::<_, i32>(2)? != 0))
706            })?
707            .filter_map(|r| r.ok())
708            .collect();
709
710        // Find best match by specificity
711        let mut best: Option<(i32, String, bool)> = None;
712        for (pattern, value, eval) in entries {
713            if pattern_matches_context(&pattern, context) {
714                let weight = calculate_pattern_weight(&pattern);
715                if best.is_none() || weight > best.as_ref().unwrap().0 {
716                    best = Some((weight, value, eval));
717                }
718            }
719        }
720
721        Ok(best.map(|(_, value, eval)| ZStyleEntry {
722            values: serde_json_to_values(&value),
723            eval,
724        }))
725    }
726
727    /// List all zstyles (for `zstyle -L`)
728    #[allow(clippy::type_complexity)]
729    pub fn list_zstyles(&self) -> rusqlite::Result<Vec<(String, String, Vec<String>, bool)>> {
730        let mut stmt = self
731            .conn
732            .prepare("SELECT pattern, style, value, eval FROM zstyles ORDER BY pattern, style")?;
733        let rows = stmt.query_map([], |row| {
734            let pattern: String = row.get(0)?;
735            let style: String = row.get(1)?;
736            let value: String = row.get(2)?;
737            let eval: bool = row.get::<_, i32>(3)? != 0;
738            Ok((pattern, style, serde_json_to_values(&value), eval))
739        })?;
740        rows.collect()
741    }
742
743    /// Count zstyles
744    pub fn zstyle_count(&self) -> rusqlite::Result<i64> {
745        self.conn
746            .query_row("SELECT COUNT(*) FROM zstyles", [], |row| row.get(0))
747    }
748
749    // =========================================================================
750    // Completion mappings (_comps)
751    // =========================================================================
752
753    /// Register a completion function for a command
754    pub fn set_comp(&self, command: &str, function: &str) -> rusqlite::Result<()> {
755        self.conn.execute(
756            "INSERT OR REPLACE INTO comps (command, function) VALUES (?1, ?2)",
757            params![command, function],
758        )?;
759        Ok(())
760    }
761
762    /// Bulk insert comps + populate FTS5 index
763    pub fn set_comps_bulk(&mut self, comps: &[(String, String)]) -> rusqlite::Result<()> {
764        let tx = self.conn.transaction()?;
765        // Clear and repopulate both tables
766        tx.execute("DELETE FROM comps", [])?;
767        tx.execute("DELETE FROM fts_comps", [])?;
768        {
769            let mut stmt = tx.prepare("INSERT INTO comps (command, function) VALUES (?1, ?2)")?;
770            let mut fts_stmt = tx.prepare("INSERT INTO fts_comps (command) VALUES (?1)")?;
771            for (command, function) in comps {
772                stmt.execute(params![command, function])?;
773                fts_stmt.execute(params![command])?;
774            }
775        }
776        tx.commit()
777    }
778
779    /// Fast prefix search using FTS5 (O(log n) vs O(n) for LIKE)
780    pub fn comps_prefix_fts(&self, prefix: &str) -> rusqlite::Result<Vec<(String, String)>> {
781        if prefix.is_empty() {
782            return self.comps_kv();
783        }
784        // FTS5 prefix search: "git*" matches git, github, gitk, etc.
785        let pattern = format!("{}*", prefix);
786        let mut stmt = self.conn.prepare(
787            "SELECT c.command, c.function FROM fts_comps f, comps c WHERE f.command MATCH ?1 AND c.command = f.command"
788        )?;
789        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
790        rows.collect()
791    }
792
793    /// Fast prefix search (LIKE with index scan, ORDER BY is free on indexed column)
794    pub fn comps_prefix(&self, prefix: &str) -> rusqlite::Result<Vec<(String, String)>> {
795        if prefix.is_empty() {
796            return self.comps_kv();
797        }
798        let pattern = format!("{}%", prefix);
799        let mut stmt = self.conn.prepare(
800            "SELECT command, function FROM comps WHERE command LIKE ?1 ORDER BY command",
801        )?;
802        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
803        rows.collect()
804    }
805
806    /// Lookup completion function for command
807    pub fn get_comp(&self, command: &str) -> rusqlite::Result<Option<String>> {
808        self.conn
809            .query_row(
810                "SELECT function FROM comps WHERE command = ?1",
811                params![command],
812                |row| row.get(0),
813            )
814            .optional()
815    }
816
817    /// Get all comps as HashMap (for compatibility)
818    pub fn get_all_comps(&self) -> rusqlite::Result<HashMap<String, String>> {
819        let mut stmt = self.conn.prepare("SELECT command, function FROM comps")?;
820        let rows = stmt.query_map([], |row| {
821            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
822        })?;
823        let mut map = HashMap::new();
824        for row in rows {
825            let (k, v) = row?;
826            map.insert(k, v);
827        }
828        Ok(map)
829    }
830
831    /// Count comps
832    pub fn comp_count(&self) -> rusqlite::Result<i64> {
833        self.conn
834            .query_row("SELECT COUNT(*) FROM comps", [], |row| row.get(0))
835    }
836
837    /// Delete a completion registration
838    pub fn delete_comp(&self, command: &str) -> rusqlite::Result<usize> {
839        self.conn
840            .execute("DELETE FROM comps WHERE command = ?1", params![command])
841    }
842
843    // =========================================================================
844    // Pattern completions (_patcomps)
845    // =========================================================================
846
847    /// Register a pattern completion
848    pub fn set_patcomp(&self, pattern: &str, function: &str) -> rusqlite::Result<()> {
849        self.conn.execute(
850            "INSERT OR REPLACE INTO patcomps (pattern, function) VALUES (?1, ?2)",
851            params![pattern, function],
852        )?;
853        Ok(())
854    }
855
856    /// Find matching pattern completion
857    pub fn find_patcomp(&self, command: &str) -> rusqlite::Result<Option<String>> {
858        let mut stmt = self
859            .conn
860            .prepare("SELECT pattern, function FROM patcomps")?;
861        let rows = stmt.query_map([], |row| {
862            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
863        })?;
864
865        for row in rows {
866            let (pattern, function) = row?;
867            if glob_matches(&pattern, command) {
868                return Ok(Some(function));
869            }
870        }
871        Ok(None)
872    }
873
874    // =========================================================================
875    // Post-pattern completions (_postpatcomps)
876    // =========================================================================
877
878    /// Register a post-pattern completion (`#compdef -P pat`).
879    pub fn set_postpatcomp(&self, pattern: &str, function: &str) -> rusqlite::Result<()> {
880        self.conn.execute(
881            "INSERT OR REPLACE INTO postpatcomps (pattern, function) VALUES (?1, ?2)",
882            params![pattern, function],
883        )?;
884        Ok(())
885    }
886
887    /// All `_postpatcomps` entries as (pattern, function) pairs.
888    pub fn postpatcomps_kv(&self) -> rusqlite::Result<Vec<(String, String)>> {
889        let mut stmt = self
890            .conn
891            .prepare("SELECT pattern, function FROM postpatcomps")?;
892        let rows = stmt.query_map([], |row| {
893            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
894        })?;
895        rows.collect()
896    }
897
898    /// Count of `_postpatcomps` entries.
899    pub fn postpatcomps_count(&self) -> rusqlite::Result<i64> {
900        self.conn
901            .query_row("SELECT COUNT(*) FROM postpatcomps", [], |row| row.get(0))
902    }
903
904    // =========================================================================
905    // Key completions
906    // =========================================================================
907
908    /// Register a key completion (for -K)
909    pub fn set_keycomp(&self, key: &str, function: &str) -> rusqlite::Result<()> {
910        self.conn.execute(
911            "INSERT OR REPLACE INTO keycomps (key, function) VALUES (?1, ?2)",
912            params![key, function],
913        )?;
914        Ok(())
915    }
916
917    /// Lookup key completion
918    pub fn get_keycomp(&self, key: &str) -> rusqlite::Result<Option<String>> {
919        self.conn
920            .query_row(
921                "SELECT function FROM keycomps WHERE key = ?1",
922                params![key],
923                |row| row.get(0),
924            )
925            .optional()
926    }
927
928    // =========================================================================
929    // Result cache
930    // =========================================================================
931
932    /// Cache completion results
933    pub fn cache_results(&self, context: &str, data: &[u8], mtime: i64) -> rusqlite::Result<()> {
934        self.conn.execute(
935            "INSERT OR REPLACE INTO cache (context, data, mtime) VALUES (?1, ?2, ?3)",
936            params![context, data, mtime],
937        )?;
938        Ok(())
939    }
940
941    /// Get cached results if not stale
942    pub fn get_cached(&self, context: &str, max_age: i64) -> rusqlite::Result<Option<Vec<u8>>> {
943        let now = std::time::SystemTime::now()
944            .duration_since(std::time::UNIX_EPOCH)
945            .unwrap()
946            .as_secs() as i64;
947
948        self.conn
949            .query_row(
950                "SELECT data FROM cache WHERE context = ?1 AND mtime > ?2",
951                params![context, now - max_age],
952                |row| row.get(0),
953            )
954            .optional()
955    }
956
957    /// Clear old cache entries
958    pub fn clear_stale_cache(&self, max_age: i64) -> rusqlite::Result<usize> {
959        let now = std::time::SystemTime::now()
960            .duration_since(std::time::UNIX_EPOCH)
961            .unwrap()
962            .as_secs() as i64;
963
964        self.conn
965            .execute("DELETE FROM cache WHERE mtime < ?1", params![now - max_age])
966    }
967
968    /// Clear all cache
969    pub fn clear_cache(&self) -> rusqlite::Result<()> {
970        self.conn.execute("DELETE FROM cache", [])?;
971        Ok(())
972    }
973
974    // =========================================================================
975    // Maintenance
976    // =========================================================================
977
978    /// Vacuum database
979    pub fn vacuum(&self) -> rusqlite::Result<()> {
980        self.conn.execute("VACUUM", [])?;
981        Ok(())
982    }
983
984    /// Get database stats
985    pub fn stats(&self) -> rusqlite::Result<CacheStats> {
986        Ok(CacheStats {
987            autoloads: self.autoload_count()?,
988            zstyles: self.zstyle_count()?,
989            comps: self.comp_count()?,
990            patcomps: self
991                .conn
992                .query_row("SELECT COUNT(*) FROM patcomps", [], |r| r.get(0))?,
993            keycomps: self
994                .conn
995                .query_row("SELECT COUNT(*) FROM keycomps", [], |r| r.get(0))?,
996            services: self
997                .conn
998                .query_row("SELECT COUNT(*) FROM services", [], |r| r.get(0))?,
999            cache_entries: self
1000                .conn
1001                .query_row("SELECT COUNT(*) FROM cache", [], |r| r.get(0))?,
1002        })
1003    }
1004}
1005
1006/// Autoload stub info
1007#[derive(Debug, Clone)]
1008pub struct AutoloadStub {
1009    /// `name` field.
1010    pub name: String,
1011    /// `source` field.
1012    pub source: String,
1013    /// `offset` field.
1014    pub offset: i64,
1015    /// `size` field.
1016    pub size: i64,
1017    /// Cached function body - if present, no need to read from source file
1018    pub body: Option<String>,
1019}
1020
1021/// zstyle entry
1022#[derive(Debug, Clone)]
1023pub struct ZStyleEntry {
1024    /// `values` field.
1025    pub values: Vec<String>,
1026    /// `eval` field.
1027    pub eval: bool,
1028}
1029
1030/// Cache statistics
1031#[derive(Debug)]
1032pub struct CacheStats {
1033    /// `autoloads` field.
1034    pub autoloads: i64,
1035    /// `zstyles` field.
1036    pub zstyles: i64,
1037    /// `comps` field.
1038    pub comps: i64,
1039    /// `patcomps` field.
1040    pub patcomps: i64,
1041    /// `keycomps` field.
1042    pub keycomps: i64,
1043    /// `services` field.
1044    pub services: i64,
1045    /// `cache_entries` field.
1046    pub cache_entries: i64,
1047}
1048
1049// Helper: serialize values to JSON
1050fn serde_values_to_json(values: &[String]) -> String {
1051    let escaped: Vec<String> = values
1052        .iter()
1053        .map(|s| format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")))
1054        .collect();
1055    format!("[{}]", escaped.join(","))
1056}
1057
1058// Helper: deserialize JSON to values
1059fn serde_json_to_values(json: &str) -> Vec<String> {
1060    let trimmed = json.trim();
1061    if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
1062        return vec![json.to_string()];
1063    }
1064
1065    let inner = &trimmed[1..trimmed.len() - 1];
1066    if inner.is_empty() {
1067        return vec![];
1068    }
1069
1070    let mut values = Vec::new();
1071    let mut current = String::new();
1072    let mut in_string = false;
1073    let mut escape = false;
1074
1075    for c in inner.chars() {
1076        if escape {
1077            current.push(c);
1078            escape = false;
1079        } else if c == '\\' {
1080            escape = true;
1081        } else if c == '"' {
1082            in_string = !in_string;
1083        } else if c == ',' && !in_string {
1084            values.push(current.trim().to_string());
1085            current = String::new();
1086        } else {
1087            current.push(c);
1088        }
1089    }
1090    if !current.is_empty() {
1091        values.push(current.trim().to_string());
1092    }
1093
1094    values
1095}
1096
1097// Helper: check if zstyle pattern matches context
1098fn pattern_matches_context(pattern: &str, context: &str) -> bool {
1099    let pat_parts: Vec<&str> = pattern.split(':').collect();
1100    let ctx_parts: Vec<&str> = context.split(':').collect();
1101
1102    if pat_parts.len() > ctx_parts.len() {
1103        return false;
1104    }
1105
1106    for (p, c) in pat_parts.iter().zip(ctx_parts.iter()) {
1107        if *p != "*" && *p != *c {
1108            return false;
1109        }
1110    }
1111
1112    true
1113}
1114
1115// Helper: calculate pattern weight for specificity
1116fn calculate_pattern_weight(pattern: &str) -> i32 {
1117    let parts: Vec<&str> = pattern.split(':').filter(|s| !s.is_empty()).collect();
1118    let mut weight = parts.len() as i32 * 100;
1119
1120    for part in &parts {
1121        if *part != "*" {
1122            weight += 10;
1123        }
1124    }
1125
1126    weight
1127}
1128
1129// Helper: glob matching for patcomps
1130fn glob_matches(pattern: &str, text: &str) -> bool {
1131    let mut pat_chars = pattern.chars().peekable();
1132    let mut txt_chars = text.chars().peekable();
1133
1134    while let Some(p) = pat_chars.next() {
1135        match p {
1136            '*' => {
1137                if pat_chars.peek().is_none() {
1138                    return true;
1139                }
1140                while txt_chars.peek().is_some() {
1141                    if glob_matches(
1142                        &pat_chars.clone().collect::<String>(),
1143                        &txt_chars.clone().collect::<String>(),
1144                    ) {
1145                        return true;
1146                    }
1147                    txt_chars.next();
1148                }
1149                return false;
1150            }
1151            '?' => {
1152                if txt_chars.next().is_none() {
1153                    return false;
1154                }
1155            }
1156            c => {
1157                if txt_chars.next() != Some(c) {
1158                    return false;
1159                }
1160            }
1161        }
1162    }
1163
1164    txt_chars.peek().is_none()
1165}
1166
1167// =========================================================================
1168// Shell-visible arrays (_comps, _services, _patcomps, etc.)
1169// These back the zsh special arrays that users query with $#_comps etc.
1170// =========================================================================
1171
1172impl CompsysCache {
1173    /// Get count of _comps entries (for $#_comps)
1174    pub fn comps_count(&self) -> rusqlite::Result<i64> {
1175        self.comp_count()
1176    }
1177
1178    /// Get all _comps keys (for ${(k)_comps}) - ORDER BY is free on PRIMARY KEY
1179    pub fn comps_keys(&self) -> rusqlite::Result<Vec<String>> {
1180        let mut stmt = self
1181            .conn
1182            .prepare("SELECT command FROM comps ORDER BY command")?;
1183        let rows = stmt.query_map([], |row| row.get(0))?;
1184        rows.collect()
1185    }
1186
1187    /// Get all _comps values (for ${(v)_comps})
1188    pub fn comps_values(&self) -> rusqlite::Result<Vec<String>> {
1189        let mut stmt = self
1190            .conn
1191            .prepare("SELECT function FROM comps ORDER BY command")?;
1192        let rows = stmt.query_map([], |row| row.get(0))?;
1193        rows.collect()
1194    }
1195
1196    /// Get _comps as key-value pairs (for ${(kv)_comps})
1197    pub fn comps_kv(&self) -> rusqlite::Result<Vec<(String, String)>> {
1198        let mut stmt = self
1199            .conn
1200            .prepare("SELECT command, function FROM comps ORDER BY command")?;
1201        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1202        rows.collect()
1203    }
1204
1205    // --- _patcomps ---
1206
1207    /// Get count of _patcomps
1208    pub fn patcomps_count(&self) -> rusqlite::Result<i64> {
1209        self.conn
1210            .query_row("SELECT COUNT(*) FROM patcomps", [], |row| row.get(0))
1211    }
1212
1213    /// Get all _patcomps keys
1214    pub fn patcomps_keys(&self) -> rusqlite::Result<Vec<String>> {
1215        let mut stmt = self.conn.prepare("SELECT pattern FROM patcomps")?;
1216        let rows = stmt.query_map([], |row| row.get(0))?;
1217        rows.collect()
1218    }
1219
1220    /// Get all _patcomps as kv
1221    pub fn patcomps_kv(&self) -> rusqlite::Result<Vec<(String, String)>> {
1222        let mut stmt = self
1223            .conn
1224            .prepare("SELECT pattern, function FROM patcomps")?;
1225        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1226        rows.collect()
1227    }
1228
1229    // --- _services ---
1230
1231    /// Set a service mapping
1232    pub fn set_service(&self, command: &str, service: &str) -> rusqlite::Result<()> {
1233        self.conn.execute(
1234            "INSERT OR REPLACE INTO services (command, service) VALUES (?1, ?2)",
1235            params![command, service],
1236        )?;
1237        Ok(())
1238    }
1239
1240    /// Get service for command
1241    pub fn get_service(&self, command: &str) -> rusqlite::Result<Option<String>> {
1242        self.conn
1243            .query_row(
1244                "SELECT service FROM services WHERE command = ?1",
1245                params![command],
1246                |row| row.get(0),
1247            )
1248            .optional()
1249    }
1250
1251    /// Get count of _services
1252    pub fn services_count(&self) -> rusqlite::Result<i64> {
1253        self.conn
1254            .query_row("SELECT COUNT(*) FROM services", [], |row| row.get(0))
1255    }
1256
1257    /// Get all _services keys
1258    pub fn services_keys(&self) -> rusqlite::Result<Vec<String>> {
1259        let mut stmt = self.conn.prepare("SELECT command FROM services")?;
1260        let rows = stmt.query_map([], |row| row.get(0))?;
1261        rows.collect()
1262    }
1263
1264    /// Bulk insert services
1265    pub fn set_services_bulk(&mut self, services: &[(String, String)]) -> rusqlite::Result<()> {
1266        let tx = self.conn.transaction()?;
1267        {
1268            let mut stmt =
1269                tx.prepare("INSERT OR REPLACE INTO services (command, service) VALUES (?1, ?2)")?;
1270            for (command, service) in services {
1271                stmt.execute(params![command, service])?;
1272            }
1273        }
1274        tx.commit()?;
1275        Ok(())
1276    }
1277
1278    // --- _compautos (autoloaded completion functions) ---
1279
1280    /// Get count of autoloaded functions
1281    pub fn compautos_count(&self) -> rusqlite::Result<i64> {
1282        self.autoload_count()
1283    }
1284
1285    /// Get all autoload names (for ${(k)_compautos})
1286    pub fn compautos_keys(&self) -> rusqlite::Result<Vec<String>> {
1287        let mut stmt = self.conn.prepare("SELECT name FROM autoloads")?;
1288        let rows = stmt.query_map([], |row| row.get(0))?;
1289        rows.collect()
1290    }
1291
1292    // =========================================================================
1293    // PATH executables cache
1294    // =========================================================================
1295
1296    /// Check if executables cache is populated
1297    pub fn has_executables(&self) -> rusqlite::Result<bool> {
1298        let count: i64 = self
1299            .conn
1300            .query_row("SELECT COUNT(*) FROM executables", [], |row| row.get(0))?;
1301        Ok(count > 0)
1302    }
1303
1304    /// Store executables in bulk + populate FTS5 index
1305    pub fn set_executables_bulk(
1306        &mut self,
1307        executables: &[(String, String)],
1308    ) -> rusqlite::Result<()> {
1309        let tx = self.conn.transaction()?;
1310        tx.execute("DELETE FROM executables", [])?;
1311        tx.execute("DELETE FROM fts_executables", [])?;
1312        {
1313            let mut stmt =
1314                tx.prepare("INSERT OR IGNORE INTO executables (name, path) VALUES (?1, ?2)")?;
1315            let mut fts_stmt =
1316                tx.prepare("INSERT OR IGNORE INTO fts_executables (name) VALUES (?1)")?;
1317            for (name, path) in executables {
1318                stmt.execute(params![name, path])?;
1319                fts_stmt.execute(params![name])?;
1320            }
1321        }
1322        tx.commit()
1323    }
1324
1325    /// Get all executable names (fast lookup set)
1326    pub fn get_executable_names(&self) -> rusqlite::Result<std::collections::HashSet<String>> {
1327        let mut stmt = self.conn.prepare("SELECT name FROM executables")?;
1328        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1329        rows.collect::<Result<std::collections::HashSet<_>, _>>()
1330    }
1331
1332    /// Check if an executable exists in cache (O(1) lookup)
1333    pub fn has_executable(&self, name: &str) -> rusqlite::Result<bool> {
1334        // Use EXISTS for faster check (stops at first match)
1335        let exists: i64 = self.conn.query_row(
1336            "SELECT EXISTS(SELECT 1 FROM executables WHERE name = ?1)",
1337            params![name],
1338            |row| row.get(0),
1339        )?;
1340        Ok(exists == 1)
1341    }
1342
1343    /// Get executable path by name (direct key lookup)
1344    pub fn get_executable_path(&self, name: &str) -> rusqlite::Result<Option<String>> {
1345        self.conn
1346            .query_row(
1347                "SELECT path FROM executables WHERE name = ?1",
1348                params![name],
1349                |row| row.get(0),
1350            )
1351            .optional()
1352    }
1353
1354    /// Fast prefix search using FTS5
1355    pub fn get_executables_prefix_fts(
1356        &self,
1357        prefix: &str,
1358    ) -> rusqlite::Result<Vec<(String, String)>> {
1359        if prefix.is_empty() {
1360            let mut stmt = self.conn.prepare("SELECT name, path FROM executables")?;
1361            let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1362            return rows.collect();
1363        }
1364        let pattern = format!("{}*", prefix);
1365        let mut stmt = self.conn.prepare(
1366            "SELECT e.name, e.path FROM fts_executables f, executables e WHERE f.name MATCH ?1 AND e.name = f.name"
1367        )?;
1368        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
1369        rows.collect()
1370    }
1371
1372    /// Get executables matching prefix (LIKE with index, ORDER BY free on PRIMARY KEY)
1373    pub fn get_executables_prefix(&self, prefix: &str) -> rusqlite::Result<Vec<(String, String)>> {
1374        if prefix.is_empty() {
1375            let mut stmt = self
1376                .conn
1377                .prepare("SELECT name, path FROM executables ORDER BY name")?;
1378            let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1379            return rows.collect();
1380        }
1381        let pattern = format!("{}%", prefix);
1382        let mut stmt = self
1383            .conn
1384            .prepare("SELECT name, path FROM executables WHERE name LIKE ?1 ORDER BY name")?;
1385        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
1386        rows.collect()
1387    }
1388
1389    /// Count executables
1390    pub fn executables_count(&self) -> rusqlite::Result<i64> {
1391        self.conn
1392            .query_row("SELECT COUNT(*) FROM executables", [], |row| row.get(0))
1393    }
1394
1395    // =========================================================================
1396    // Named directories cache (hash -d)
1397    // =========================================================================
1398
1399    /// Check if named_dirs cache is populated
1400    pub fn has_named_dirs(&self) -> rusqlite::Result<bool> {
1401        let count: i64 = self
1402            .conn
1403            .query_row("SELECT COUNT(*) FROM named_dirs", [], |row| row.get(0))?;
1404        Ok(count > 0)
1405    }
1406
1407    /// Store named directories in bulk (clears existing)
1408    pub fn set_named_dirs_bulk(&mut self, dirs: &[(String, String)]) -> rusqlite::Result<()> {
1409        let tx = self.conn.transaction()?;
1410        tx.execute("DELETE FROM named_dirs", [])?;
1411        {
1412            let mut stmt = tx.prepare("INSERT INTO named_dirs (name, path) VALUES (?1, ?2)")?;
1413            for (name, path) in dirs {
1414                stmt.execute(params![name, path])?;
1415            }
1416        }
1417        tx.commit()
1418    }
1419
1420    /// Get all named directories (ORDER BY free on PRIMARY KEY)
1421    pub fn get_named_dirs(&self) -> rusqlite::Result<Vec<(String, String)>> {
1422        let mut stmt = self
1423            .conn
1424            .prepare("SELECT name, path FROM named_dirs ORDER BY name")?;
1425        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1426        rows.collect()
1427    }
1428
1429    /// Get named directories matching prefix
1430    pub fn get_named_dirs_prefix(&self, prefix: &str) -> rusqlite::Result<Vec<(String, String)>> {
1431        if prefix.is_empty() {
1432            return self.get_named_dirs();
1433        }
1434        let pattern = format!("{}%", prefix);
1435        let mut stmt = self
1436            .conn
1437            .prepare("SELECT name, path FROM named_dirs WHERE name LIKE ?1 ORDER BY name")?;
1438        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
1439        rows.collect()
1440    }
1441
1442    /// Count named directories
1443    pub fn named_dirs_count(&self) -> rusqlite::Result<i64> {
1444        self.conn
1445            .query_row("SELECT COUNT(*) FROM named_dirs", [], |row| row.get(0))
1446    }
1447
1448    // =========================================================================
1449    // Shell functions cache (FPATH)
1450    // =========================================================================
1451
1452    /// Check if shell_functions cache is populated
1453    pub fn has_shell_functions(&self) -> rusqlite::Result<bool> {
1454        let count: i64 =
1455            self.conn
1456                .query_row("SELECT COUNT(*) FROM shell_functions", [], |row| row.get(0))?;
1457        Ok(count > 0)
1458    }
1459
1460    /// Store shell functions in bulk + populate FTS5 index
1461    pub fn set_shell_functions_bulk(&mut self, funcs: &[(String, String)]) -> rusqlite::Result<()> {
1462        let tx = self.conn.transaction()?;
1463        tx.execute("DELETE FROM shell_functions", [])?;
1464        tx.execute("DELETE FROM fts_shell_functions", [])?;
1465        {
1466            let mut stmt =
1467                tx.prepare("INSERT OR IGNORE INTO shell_functions (name, source) VALUES (?1, ?2)")?;
1468            let mut fts_stmt =
1469                tx.prepare("INSERT OR IGNORE INTO fts_shell_functions (name) VALUES (?1)")?;
1470            for (name, source) in funcs {
1471                stmt.execute(params![name, source])?;
1472                fts_stmt.execute(params![name])?;
1473            }
1474        }
1475        tx.commit()
1476    }
1477
1478    /// Get all shell function names (ORDER BY free on PRIMARY KEY)
1479    pub fn get_shell_function_names(&self) -> rusqlite::Result<Vec<String>> {
1480        let mut stmt = self
1481            .conn
1482            .prepare("SELECT name FROM shell_functions ORDER BY name")?;
1483        let rows = stmt.query_map([], |row| row.get(0))?;
1484        rows.collect()
1485    }
1486
1487    /// Get shell functions with source paths
1488    pub fn get_shell_functions(&self) -> rusqlite::Result<Vec<(String, String)>> {
1489        let mut stmt = self
1490            .conn
1491            .prepare("SELECT name, source FROM shell_functions ORDER BY name")?;
1492        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
1493        rows.collect()
1494    }
1495
1496    /// Fast prefix search using FTS5 (note: FTS5 doesn't preserve order, needs post-sort)
1497    pub fn get_shell_functions_prefix_fts(
1498        &self,
1499        prefix: &str,
1500    ) -> rusqlite::Result<Vec<(String, String)>> {
1501        if prefix.is_empty() {
1502            return self.get_shell_functions();
1503        }
1504        let pattern = format!("{}*", prefix);
1505        let mut stmt = self.conn.prepare(
1506            "SELECT s.name, s.source FROM fts_shell_functions f, shell_functions s WHERE f.name MATCH ?1 AND s.name = f.name ORDER BY s.name"
1507        )?;
1508        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
1509        rows.collect()
1510    }
1511
1512    /// Get shell functions matching prefix (LIKE with index, ORDER BY free)
1513    pub fn get_shell_functions_prefix(
1514        &self,
1515        prefix: &str,
1516    ) -> rusqlite::Result<Vec<(String, String)>> {
1517        if prefix.is_empty() {
1518            return self.get_shell_functions();
1519        }
1520        let pattern = format!("{}%", prefix);
1521        let mut stmt = self
1522            .conn
1523            .prepare("SELECT name, source FROM shell_functions WHERE name LIKE ?1 ORDER BY name")?;
1524        let rows = stmt.query_map(params![pattern], |row| Ok((row.get(0)?, row.get(1)?)))?;
1525        rows.collect()
1526    }
1527
1528    /// Count shell functions
1529    pub fn shell_functions_count(&self) -> rusqlite::Result<i64> {
1530        self.conn
1531            .query_row("SELECT COUNT(*) FROM shell_functions", [], |row| row.get(0))
1532    }
1533
1534    // =========================================================================
1535    // Metadata for cache versioning/invalidation
1536    // =========================================================================
1537
1538    /// Set metadata key-value
1539    pub fn set_metadata(&self, key: &str, value: &str) -> rusqlite::Result<()> {
1540        self.conn.execute(
1541            "INSERT OR REPLACE INTO metadata (key, value) VALUES (?1, ?2)",
1542            params![key, value],
1543        )?;
1544        Ok(())
1545    }
1546
1547    /// Get metadata value
1548    pub fn get_metadata(&self, key: &str) -> rusqlite::Result<Option<String>> {
1549        self.conn
1550            .query_row(
1551                "SELECT value FROM metadata WHERE key = ?1",
1552                params![key],
1553                |row| row.get(0),
1554            )
1555            .optional()
1556    }
1557
1558    // =========================================================================
1559    // Zstyle helpers
1560    // =========================================================================
1561
1562    /// Check if zstyles cache is populated
1563    pub fn has_zstyles(&self) -> rusqlite::Result<bool> {
1564        let count: i64 = self
1565            .conn
1566            .query_row("SELECT COUNT(*) FROM zstyles", [], |row| row.get(0))?;
1567        Ok(count > 0)
1568    }
1569
1570    /// Count zstyles
1571    pub fn zstyles_count(&self) -> rusqlite::Result<i64> {
1572        self.conn
1573            .query_row("SELECT COUNT(*) FROM zstyles", [], |row| row.get(0))
1574    }
1575
1576    /// Get all zstyles (for debugging)
1577    pub fn get_all_zstyles(&self) -> rusqlite::Result<Vec<(String, String, String)>> {
1578        let mut stmt = self
1579            .conn
1580            .prepare("SELECT pattern, style, value FROM zstyles ORDER BY pattern, style")?;
1581        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
1582        rows.collect()
1583    }
1584}
1585
1586#[cfg(test)]
1587mod tests {
1588    use super::*;
1589
1590    #[test]
1591    fn test_cache_basic() {
1592        let cache = CompsysCache::memory().unwrap();
1593
1594        cache
1595            .add_autoload("_git", "more_src.zwc", 1024, 5000)
1596            .unwrap();
1597        cache
1598            .add_autoload("_docker", "more_src.zwc", 6024, 3000)
1599            .unwrap();
1600
1601        let stub = cache.get_autoload("_git").unwrap().unwrap();
1602        assert_eq!(stub.source, "more_src.zwc");
1603        assert_eq!(stub.offset, 1024);
1604
1605        assert!(cache.get_autoload("_nonexistent").unwrap().is_none());
1606    }
1607
1608    #[test]
1609    fn test_zstyle_cache() {
1610        let cache = CompsysCache::memory().unwrap();
1611
1612        cache
1613            .set_zstyle(":completion:*", "menu", &["select".to_string()], false)
1614            .unwrap();
1615        cache
1616            .set_zstyle(
1617                ":completion:*:descriptions",
1618                "format",
1619                &["%d".to_string()],
1620                false,
1621            )
1622            .unwrap();
1623
1624        let entry = cache
1625            .lookup_zstyle(":completion:foo", "menu")
1626            .unwrap()
1627            .unwrap();
1628        assert_eq!(entry.values, vec!["select"]);
1629
1630        let entry = cache
1631            .lookup_zstyle(":completion:foo:descriptions", "format")
1632            .unwrap()
1633            .unwrap();
1634        assert_eq!(entry.values, vec!["%d"]);
1635    }
1636
1637    #[test]
1638    fn test_zstyle_specificity() {
1639        let cache = CompsysCache::memory().unwrap();
1640
1641        cache
1642            .set_zstyle(":completion:*", "menu", &["no".to_string()], false)
1643            .unwrap();
1644        cache
1645            .set_zstyle(
1646                ":completion:*:*:*:default",
1647                "menu",
1648                &["yes".to_string()],
1649                false,
1650            )
1651            .unwrap();
1652
1653        let entry = cache
1654            .lookup_zstyle(":completion:foo:bar:baz:default", "menu")
1655            .unwrap()
1656            .unwrap();
1657        assert_eq!(entry.values, vec!["yes"]);
1658    }
1659
1660    #[test]
1661    fn test_comps_cache() {
1662        let mut cache = CompsysCache::memory().unwrap();
1663
1664        let comps = vec![
1665            ("git".to_string(), "_git".to_string()),
1666            ("docker".to_string(), "_docker".to_string()),
1667            ("cargo".to_string(), "_cargo".to_string()),
1668        ];
1669        cache.set_comps_bulk(&comps).unwrap();
1670
1671        assert_eq!(cache.get_comp("git").unwrap(), Some("_git".to_string()));
1672        assert_eq!(
1673            cache.get_comp("docker").unwrap(),
1674            Some("_docker".to_string())
1675        );
1676        assert!(cache.get_comp("nonexistent").unwrap().is_none());
1677
1678        assert_eq!(cache.comp_count().unwrap(), 3);
1679    }
1680
1681    #[test]
1682    fn test_bulk_autoloads() {
1683        let mut cache = CompsysCache::memory().unwrap();
1684
1685        let autoloads: Vec<(String, String, i64, i64)> = (0..1000)
1686            .map(|i| (format!("_func{}", i), "test.zwc".to_string(), i * 100, 100))
1687            .collect();
1688
1689        cache.add_autoloads_bulk(&autoloads).unwrap();
1690        assert_eq!(cache.autoload_count().unwrap(), 1000);
1691
1692        let stub = cache.get_autoload("_func500").unwrap().unwrap();
1693        assert_eq!(stub.offset, 50000);
1694        assert!(stub.body.is_none()); // No body when bulk inserted without
1695    }
1696
1697    #[test]
1698    fn test_autoload_with_body() {
1699        let cache = CompsysCache::memory().unwrap();
1700
1701        let body = r#"
1702local -a opts
1703opts=(--help --version --verbose)
1704_arguments $opts
1705"#;
1706        cache
1707            .add_autoload_with_body("_mycommand", "/usr/share/zsh/functions/_mycommand", body)
1708            .unwrap();
1709
1710        let stub = cache.get_autoload("_mycommand").unwrap().unwrap();
1711        assert_eq!(stub.body.as_deref(), Some(body));
1712        assert_eq!(stub.size, body.len() as i64);
1713
1714        // Fast path: get body directly
1715        let direct_body = cache.get_autoload_body("_mycommand").unwrap();
1716        assert_eq!(direct_body.as_deref(), Some(body));
1717    }
1718
1719    #[test]
1720    fn test_bulk_autoloads_with_bodies() {
1721        let mut cache = CompsysCache::memory().unwrap();
1722
1723        let autoloads: Vec<(String, String, String)> = (0..100)
1724            .map(|i| {
1725                (
1726                    format!("_func{}", i),
1727                    format!("/path/to/_func{}", i),
1728                    format!("# Function {}\necho hello", i),
1729                )
1730            })
1731            .collect();
1732
1733        cache.add_autoloads_with_bodies_bulk(&autoloads).unwrap();
1734        assert_eq!(cache.autoload_count().unwrap(), 100);
1735
1736        let stub = cache.get_autoload("_func50").unwrap().unwrap();
1737        assert!(stub.body.is_some());
1738        assert!(stub.body.unwrap().contains("Function 50"));
1739    }
1740
1741    #[test]
1742    fn test_get_autoload_body_or_zwc_with_body() {
1743        let cache = CompsysCache::memory().unwrap();
1744
1745        let body = "echo from sqlite";
1746        cache
1747            .add_autoload_with_body("_cached", "/some/path", body)
1748            .unwrap();
1749
1750        // Should return body from SQLite (fast path)
1751        let result = cache.get_autoload_body_or_zwc("_cached");
1752        assert_eq!(result, Some(body.to_string()));
1753    }
1754
1755    #[test]
1756    fn test_get_autoload_body_or_zwc_no_body() {
1757        let cache = CompsysCache::memory().unwrap();
1758
1759        // Add autoload without body (just ZWC reference)
1760        cache
1761            .add_autoload("_nocache", "nonexistent.zwc", 0, 100)
1762            .unwrap();
1763
1764        // Should return None since ZWC file doesn't exist
1765        let result = cache.get_autoload_body_or_zwc("_nocache");
1766        assert!(result.is_none());
1767    }
1768
1769    #[test]
1770    fn test_get_autoload_body_or_zwc_not_found() {
1771        let cache = CompsysCache::memory().unwrap();
1772
1773        // Function doesn't exist at all
1774        let result = cache.get_autoload_body_or_zwc("_nonexistent");
1775        assert!(result.is_none());
1776    }
1777
1778    #[test]
1779    fn test_patcomp() {
1780        let cache = CompsysCache::memory().unwrap();
1781
1782        cache.set_patcomp("git-*", "_git").unwrap();
1783        cache.set_patcomp("docker-*", "_docker").unwrap();
1784
1785        assert_eq!(
1786            cache.find_patcomp("git-commit").unwrap(),
1787            Some("_git".to_string())
1788        );
1789        assert_eq!(
1790            cache.find_patcomp("docker-compose").unwrap(),
1791            Some("_docker".to_string())
1792        );
1793        assert!(cache.find_patcomp("cargo").unwrap().is_none());
1794    }
1795
1796    #[test]
1797    fn test_glob_matches() {
1798        assert!(glob_matches("git-*", "git-commit"));
1799        assert!(glob_matches("*-compose", "docker-compose"));
1800        // `*.rs` cannot match `zle_main` (no `.rs` extension); the
1801        // glob is anchored at both ends, and `.rs` is literal.
1802        assert!(!glob_matches("*.rs", "zle_main"));
1803        assert!(!glob_matches("git-*", "docker-compose"));
1804        assert!(glob_matches("???", "abc"));
1805        assert!(!glob_matches("???", "abcd"));
1806    }
1807
1808    #[test]
1809    fn test_json_serde() {
1810        let values = vec!["hello".to_string(), "world".to_string()];
1811        let json = serde_values_to_json(&values);
1812        let back = serde_json_to_values(&json);
1813        assert_eq!(back, values);
1814
1815        let values = vec!["with \"quotes\"".to_string()];
1816        let json = serde_values_to_json(&values);
1817        let back = serde_json_to_values(&json);
1818        assert_eq!(back, vec!["with \"quotes\""]);
1819    }
1820
1821    #[test]
1822    fn test_stats() {
1823        let mut cache = CompsysCache::memory().unwrap();
1824
1825        cache.add_autoload("_git", "test.zwc", 0, 100).unwrap();
1826        cache
1827            .set_zstyle(":completion:*", "menu", &["select".to_string()], false)
1828            .unwrap();
1829        cache.set_comp("git", "_git").unwrap();
1830
1831        let stats = cache.stats().unwrap();
1832        assert_eq!(stats.autoloads, 1);
1833        assert_eq!(stats.zstyles, 1);
1834        assert_eq!(stats.comps, 1);
1835    }
1836
1837    #[test]
1838    fn test_large_scale() {
1839        let mut cache = CompsysCache::memory().unwrap();
1840
1841        // Simulate 500k autoloads
1842        let autoloads: Vec<(String, String, i64, i64)> = (0..10000)
1843            .map(|i| {
1844                (
1845                    format!("_func{}", i),
1846                    format!("src{}.zwc", i % 10),
1847                    i * 50,
1848                    50,
1849                )
1850            })
1851            .collect();
1852
1853        cache.add_autoloads_bulk(&autoloads).unwrap();
1854
1855        // Fast lookup
1856        let stub = cache.get_autoload("_func9999").unwrap().unwrap();
1857        assert_eq!(stub.offset, 9999 * 50);
1858
1859        assert_eq!(cache.autoload_count().unwrap(), 10000);
1860    }
1861
1862    #[test]
1863    fn test_executables_cache() {
1864        let mut cache = CompsysCache::memory().unwrap();
1865
1866        let executables = vec![
1867            ("ls".to_string(), "/bin/ls".to_string()),
1868            ("cat".to_string(), "/bin/cat".to_string()),
1869            ("git".to_string(), "/usr/bin/git".to_string()),
1870        ];
1871        cache.set_executables_bulk(&executables).unwrap();
1872
1873        assert!(cache.has_executables().unwrap());
1874        assert!(cache.has_executable("ls").unwrap());
1875        assert!(cache.has_executable("git").unwrap());
1876        assert!(!cache.has_executable("nonexistent").unwrap());
1877
1878        assert_eq!(
1879            cache.get_executable_path("ls").unwrap(),
1880            Some("/bin/ls".to_string())
1881        );
1882        assert_eq!(cache.executables_count().unwrap(), 3);
1883    }
1884
1885    #[test]
1886    fn test_executables_prefix_search() {
1887        let mut cache = CompsysCache::memory().unwrap();
1888
1889        let executables = vec![
1890            ("git".to_string(), "/usr/bin/git".to_string()),
1891            ("gitk".to_string(), "/usr/bin/gitk".to_string()),
1892            ("grep".to_string(), "/bin/grep".to_string()),
1893            ("gzip".to_string(), "/bin/gzip".to_string()),
1894        ];
1895        cache.set_executables_bulk(&executables).unwrap();
1896
1897        // FTS prefix search returns (name, path) tuples
1898        let git_cmds = cache.get_executables_prefix_fts("git").unwrap();
1899        assert_eq!(git_cmds.len(), 2);
1900        assert!(git_cmds.iter().any(|(name, _)| name == "git"));
1901        assert!(git_cmds.iter().any(|(name, _)| name == "gitk"));
1902
1903        let g_cmds = cache.get_executables_prefix_fts("g").unwrap();
1904        assert_eq!(g_cmds.len(), 4);
1905    }
1906
1907    #[test]
1908    fn test_named_dirs_cache() {
1909        let mut cache = CompsysCache::memory().unwrap();
1910
1911        let dirs = vec![
1912            ("proj".to_string(), "/home/user/projects".to_string()),
1913            ("docs".to_string(), "/home/user/documents".to_string()),
1914        ];
1915        cache.set_named_dirs_bulk(&dirs).unwrap();
1916
1917        assert!(cache.has_named_dirs().unwrap());
1918
1919        let all = cache.get_named_dirs().unwrap();
1920        assert_eq!(all.len(), 2);
1921
1922        let p_dirs = cache.get_named_dirs_prefix("p").unwrap();
1923        assert_eq!(p_dirs.len(), 1);
1924        assert_eq!(p_dirs[0].0, "proj");
1925    }
1926
1927    #[test]
1928    fn test_shell_functions_cache() {
1929        let mut cache = CompsysCache::memory().unwrap();
1930
1931        let functions = vec![
1932            ("myFunc".to_string(), "/home/user/.zshrc".to_string()),
1933            (
1934                "zpwrClearList".to_string(),
1935                "/home/user/.zpwr/autoload".to_string(),
1936            ),
1937            (
1938                "zpwrTop".to_string(),
1939                "/home/user/.zpwr/autoload".to_string(),
1940            ),
1941        ];
1942        cache.set_shell_functions_bulk(&functions).unwrap();
1943
1944        assert!(cache.has_shell_functions().unwrap());
1945        assert_eq!(cache.shell_functions_count().unwrap(), 3);
1946
1947        let zpwr = cache.get_shell_functions_prefix("zpwr").unwrap();
1948        assert_eq!(zpwr.len(), 2);
1949        // Results are tuples (name, source)
1950        assert!(zpwr.iter().any(|(name, _)| name == "zpwrClearList"));
1951        assert!(zpwr.iter().any(|(name, _)| name == "zpwrTop"));
1952    }
1953
1954    #[test]
1955    fn test_metadata() {
1956        let cache = CompsysCache::memory().unwrap();
1957
1958        cache.set_metadata("version", "1.0.0").unwrap();
1959        cache.set_metadata("build_time", "2026-04-22").unwrap();
1960
1961        assert_eq!(
1962            cache.get_metadata("version").unwrap(),
1963            Some("1.0.0".to_string())
1964        );
1965        assert_eq!(
1966            cache.get_metadata("build_time").unwrap(),
1967            Some("2026-04-22".to_string())
1968        );
1969        assert_eq!(cache.get_metadata("nonexistent").unwrap(), None);
1970    }
1971
1972    #[test]
1973    fn test_comps_keys() {
1974        let mut cache = CompsysCache::memory().unwrap();
1975
1976        let comps = vec![
1977            ("git".to_string(), "_git".to_string()),
1978            ("docker".to_string(), "_docker".to_string()),
1979        ];
1980        cache.set_comps_bulk(&comps).unwrap();
1981
1982        let keys = cache.comps_keys().unwrap();
1983        assert_eq!(keys.len(), 2);
1984        assert!(keys.contains(&"docker".to_string()));
1985        assert!(keys.contains(&"git".to_string()));
1986    }
1987
1988    #[test]
1989    fn test_comps_prefix() {
1990        let mut cache = CompsysCache::memory().unwrap();
1991
1992        let comps = vec![
1993            ("git".to_string(), "_git".to_string()),
1994            ("gitk".to_string(), "_gitk".to_string()),
1995            ("docker".to_string(), "_docker".to_string()),
1996        ];
1997        cache.set_comps_bulk(&comps).unwrap();
1998
1999        let git_comps = cache.comps_prefix("git").unwrap();
2000        assert_eq!(git_comps.len(), 2);
2001    }
2002
2003    #[test]
2004    fn test_zstyles_bulk() {
2005        let mut cache = CompsysCache::memory().unwrap();
2006
2007        let styles = vec![
2008            (
2009                ":completion:*".to_string(),
2010                "menu".to_string(),
2011                vec!["select".to_string()],
2012                false,
2013            ),
2014            (
2015                ":completion:*".to_string(),
2016                "verbose".to_string(),
2017                vec!["yes".to_string()],
2018                false,
2019            ),
2020            (
2021                ":completion:*:descriptions".to_string(),
2022                "format".to_string(),
2023                vec!["%d".to_string()],
2024                false,
2025            ),
2026        ];
2027        cache.set_zstyles_bulk(&styles).unwrap();
2028
2029        assert!(cache.has_zstyles().unwrap());
2030        assert_eq!(cache.zstyles_count().unwrap(), 3);
2031    }
2032
2033    #[test]
2034    fn test_services() {
2035        let cache = CompsysCache::memory().unwrap();
2036
2037        cache.set_service("git", "scm").unwrap();
2038        cache.set_service("hg", "scm").unwrap();
2039
2040        assert_eq!(cache.get_service("git").unwrap(), Some("scm".to_string()));
2041        assert_eq!(cache.get_service("unknown").unwrap(), None);
2042    }
2043
2044    #[test]
2045    fn test_cache_overwrite() {
2046        let cache = CompsysCache::memory().unwrap();
2047
2048        cache.set_comp("git", "_git_old").unwrap();
2049        assert_eq!(cache.get_comp("git").unwrap(), Some("_git_old".to_string()));
2050
2051        cache.set_comp("git", "_git_new").unwrap();
2052        assert_eq!(cache.get_comp("git").unwrap(), Some("_git_new".to_string()));
2053    }
2054
2055    #[test]
2056    fn test_executable_names() {
2057        let mut cache = CompsysCache::memory().unwrap();
2058
2059        let executables = vec![
2060            ("alpha".to_string(), "/bin/alpha".to_string()),
2061            ("beta".to_string(), "/bin/beta".to_string()),
2062            ("gamma".to_string(), "/bin/gamma".to_string()),
2063        ];
2064        cache.set_executables_bulk(&executables).unwrap();
2065
2066        let names = cache.get_executable_names().unwrap();
2067        assert_eq!(names.len(), 3);
2068        // Returns a HashSet, so check contains
2069        assert!(names.contains("alpha"));
2070        assert!(names.contains("beta"));
2071        assert!(names.contains("gamma"));
2072    }
2073
2074    #[test]
2075    fn postpatcomps_do_not_land_in_patcomps() {
2076        // Regression: `build_cache_from_fpath` used to write every
2077        // `#compdef -P` pattern into the `patcomps` table ("For now, we'll
2078        // merge them into patcomps"), and `load_from_cache` never read a
2079        // post-pattern back at all. On the `compinit -C` path that left
2080        // `$_postpatcomps` EMPTY and `$_patcomps` holding all 25 upstream
2081        // post-patterns, so `_dispatch` ran `-P` completers in its PRE
2082        // pass — before the `$_comps` lookup and without the
2083        // `_compskip=default` that the post pass sets. `PATH=/usr/bin:<TAB>`
2084        // therefore ran `_dir_list` AND the `-default-` fallback, listing
2085        // every file instead of only directories.
2086        let cache = CompsysCache::memory().unwrap();
2087        cache
2088            .set_patcomp("*/(init|rc[0-9S]#).d/*", "_init_d")
2089            .unwrap();
2090        cache
2091            .set_postpatcomp("-value-,*PATH,-default-", "_dir_list")
2092            .unwrap();
2093
2094        let pat = cache.patcomps_kv().unwrap();
2095        assert_eq!(
2096            pat,
2097            vec![("*/(init|rc[0-9S]#).d/*".to_string(), "_init_d".to_string())]
2098        );
2099
2100        let post = cache.postpatcomps_kv().unwrap();
2101        assert_eq!(
2102            post,
2103            vec![(
2104                "-value-,*PATH,-default-".to_string(),
2105                "_dir_list".to_string()
2106            )]
2107        );
2108        assert_eq!(cache.postpatcomps_count().unwrap(), 1);
2109        assert_eq!(cache.patcomps_count().unwrap(), 1);
2110    }
2111
2112    #[test]
2113    fn generation_one_completion_tables_are_rebuilt() {
2114        // A cache written before the split cannot say which of its
2115        // `patcomps` rows were really `-P` post-patterns, so the whole
2116        // completion mapping set is dropped and `compinit` re-scans.
2117        // Emptying `comps` is what makes `compinit::cache_is_valid` false.
2118        let mut cache = CompsysCache::memory().unwrap();
2119        cache
2120            .set_comps_bulk(&[("git".to_string(), "_git".to_string())])
2121            .unwrap();
2122        cache
2123            .set_patcomp("-value-,*PATH,-default-", "_dir_list")
2124            .unwrap();
2125        // Pretend this DB predates the split.
2126        cache
2127            .conn
2128            .execute("DELETE FROM metadata WHERE key = 'completion_schema'", [])
2129            .unwrap();
2130
2131        cache.migrate_completion_tables().unwrap();
2132
2133        assert_eq!(
2134            cache.comp_count().unwrap(),
2135            0,
2136            "stale comps must be dropped"
2137        );
2138        assert_eq!(cache.patcomps_count().unwrap(), 0);
2139        assert_eq!(
2140            cache.get_metadata("completion_schema").unwrap().as_deref(),
2141            Some(CompsysCache::COMPLETION_SCHEMA_GENERATION)
2142        );
2143    }
2144}