Skip to main content

zsh/extensions/
plugin_cache.rs

1//! Plugin source cache — stores side effects of `source`/`.` in
2//! SQLite.
3//!
4//! **zshrs-original infrastructure with strong C-zsh ancestry.** C
5//! zsh has `bin_zcompile()` (Src/parse.c) which writes a parsed
6//! AST to a `.zwc` file alongside the source so subsequent reads
7//! skip parsing. zshrs takes the idea further: rather than caching
8//! the AST and still re-running it, we capture the *side effects*
9//! (params/aliases/options/funcs set) and replay those directly —
10//! microseconds instead of milliseconds for plugin startup. The
11//! key/invalidation model (canonical-path + mtime) matches the
12//! `.zwc` invalidation scheme C zsh uses in `try_source_file()`
13//! (Src/init.c:1551).
14//!
15//! First source: execute normally, capture state delta, write
16//! cache on worker thread.
17//! Subsequent sources: check mtime, replay cached side effects in
18//! microseconds.
19//!
20//! Cache key: `(canonical_path, mtime_secs, mtime_nsecs)`.
21//! Cache invalidation: mtime mismatch → re-source, update cache.
22
23use crate::ported::utils::{errflag, ERRFLAG_ERROR};
24#[allow(unused_imports)]
25use crate::ported::vm_helper::ShellExecutor;
26use crate::ported::zsh_h::PM_UNDEFINED;
27use rusqlite::{params, Connection};
28use std::collections::HashMap;
29use std::env;
30use std::os::unix::fs::MetadataExt;
31use std::path::{Path, PathBuf};
32use std::sync::atomic::Ordering;
33use std::sync::OnceLock;
34
35/// State snapshot for plugin delta computation.
36pub(crate) struct PluginSnapshot {
37    pub(crate) functions: std::collections::HashSet<String>,
38    pub(crate) aliases: std::collections::HashSet<String>,
39    pub(crate) global_aliases: std::collections::HashSet<String>,
40    pub(crate) suffix_aliases: std::collections::HashSet<String>,
41    pub(crate) variables: HashMap<String, String>,
42    pub(crate) arrays: std::collections::HashSet<String>,
43    pub(crate) assoc_arrays: std::collections::HashSet<String>,
44    pub(crate) fpath: Vec<PathBuf>,
45    pub(crate) options: HashMap<String, bool>,
46    pub(crate) hooks: HashMap<String, Vec<String>>,
47    pub(crate) autoloads: std::collections::HashSet<String>,
48}
49
50/// Mtime (seconds since epoch) of the running zshrs binary. Same
51/// helper as `script_cache::current_binary_mtime_secs` — we duplicate
52/// it here so plugin_cache doesn't need to take a script_cache dep
53/// and so the OnceLock is per-cache (the value is identical anyway
54/// since it's process-global). Returns None if the executable's
55/// metadata can't be read (extremely rare — usually only if the
56/// binary was deleted out from under us mid-run).
57fn current_binary_mtime() -> Option<i64> {
58    static BIN_MTIME: OnceLock<Option<i64>> = OnceLock::new();
59    *BIN_MTIME.get_or_init(|| {
60        let exe = std::env::current_exe().ok()?;
61        let meta = std::fs::metadata(&exe).ok()?;
62        Some(meta.mtime())
63    })
64}
65
66// Script bytecode caching used to live here behind the BYTECODE_VERSION
67// prefix + script_bytecode SQLite table. It now lives in the rkyv shard at
68// ~/.zshrs/scripts.rkyv (see `crate::script_cache`). The header in
69// that shard carries its own version pin (`zshrs_version`) so this prefix
70// byte is no longer needed — a zshrs rebuild silently invalidates all
71// cached entries via `binary_mtime_at_cache`.
72
73/// Side effects captured from sourcing a plugin file.
74#[derive(Debug, Clone, Default)]
75pub struct PluginDelta {
76    pub functions: Vec<(String, Vec<u8>)>, // name → bincode-serialized bytecode
77    pub aliases: Vec<(String, String, AliasKind)>, // name → value, kind
78    /// `global_aliases` field.
79    pub global_aliases: Vec<(String, String)>,
80    /// `suffix_aliases` field.
81    pub suffix_aliases: Vec<(String, String)>,
82    /// `variables` field.
83    pub variables: Vec<(String, String)>,
84    pub exports: Vec<(String, String)>, // also set in env
85    /// `arrays` field.
86    pub arrays: Vec<(String, Vec<String>)>,
87    /// `assoc_arrays` field.
88    pub assoc_arrays: Vec<(String, HashMap<String, String>)>,
89    pub completions: Vec<(String, String)>, // command → function
90    /// `fpath_additions` field.
91    pub fpath_additions: Vec<String>,
92    pub hooks: Vec<(String, String)>, // hook_name → function
93    pub bindkeys: Vec<(String, String, String)>, // keyseq, widget, keymap
94    pub zstyles: Vec<(String, String, String)>, // pattern, style, value
95    pub options_changed: Vec<(String, bool)>, // option → on/off
96    pub autoloads: Vec<(String, String)>, // function → flags
97}
98/// `AliasKind` — see variants.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum AliasKind {
101    /// `Regular` variant.
102    Regular,
103    /// `Global` variant.
104    Global,
105    /// `Suffix` variant.
106    Suffix,
107}
108
109impl AliasKind {
110    fn as_i32(self) -> i32 {
111        match self {
112            AliasKind::Regular => 0,
113            AliasKind::Global => 1,
114            AliasKind::Suffix => 2,
115        }
116    }
117    fn from_i32(v: i32) -> Self {
118        match v {
119            1 => AliasKind::Global,
120            2 => AliasKind::Suffix,
121            _ => AliasKind::Regular,
122        }
123    }
124}
125
126/// SQLite-backed plugin cache.
127pub struct PluginCache {
128    /// `conn` field.
129    conn: Connection,
130}
131
132impl PluginCache {
133    /// `open` — see implementation.
134    pub fn open(path: &Path) -> rusqlite::Result<Self> {
135        let conn = Connection::open(path)?;
136        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
137        let cache = Self { conn };
138        cache.init_schema()?;
139        Ok(cache)
140    }
141
142    fn init_schema(&self) -> rusqlite::Result<()> {
143        self.conn.execute_batch(
144            r#"
145            CREATE TABLE IF NOT EXISTS plugins (
146                id INTEGER PRIMARY KEY,
147                path TEXT NOT NULL UNIQUE,
148                mtime_secs INTEGER NOT NULL,
149                mtime_nsecs INTEGER NOT NULL,
150                source_time_ms INTEGER NOT NULL,
151                cached_at INTEGER NOT NULL,
152                binary_mtime INTEGER NOT NULL DEFAULT 0
153            );
154
155            CREATE TABLE IF NOT EXISTS plugin_functions (
156                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
157                name TEXT NOT NULL,
158                body BLOB NOT NULL
159            );
160
161            CREATE TABLE IF NOT EXISTS plugin_aliases (
162                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
163                name TEXT NOT NULL,
164                value TEXT NOT NULL,
165                kind INTEGER NOT NULL DEFAULT 0
166            );
167
168            CREATE TABLE IF NOT EXISTS plugin_variables (
169                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
170                name TEXT NOT NULL,
171                value TEXT NOT NULL,
172                is_export INTEGER NOT NULL DEFAULT 0
173            );
174
175            CREATE TABLE IF NOT EXISTS plugin_arrays (
176                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
177                name TEXT NOT NULL,
178                value_json TEXT NOT NULL
179            );
180
181            -- Associative-array deltas (e.g. ZINIT[BIN_DIR]=...). Stored
182            -- as JSON {key: value} so insertion order isn't load-bearing
183            -- (matches HashMap semantics on the Rust side). Direct
184            -- analogue of plugin_arrays for assoc shape.
185            CREATE TABLE IF NOT EXISTS plugin_assoc_arrays (
186                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
187                name TEXT NOT NULL,
188                value_json TEXT NOT NULL
189            );
190
191            CREATE TABLE IF NOT EXISTS plugin_completions (
192                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
193                command TEXT NOT NULL,
194                function TEXT NOT NULL
195            );
196
197            CREATE TABLE IF NOT EXISTS plugin_fpath (
198                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
199                path TEXT NOT NULL
200            );
201
202            CREATE TABLE IF NOT EXISTS plugin_hooks (
203                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
204                hook TEXT NOT NULL,
205                function TEXT NOT NULL
206            );
207
208            CREATE TABLE IF NOT EXISTS plugin_bindkeys (
209                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
210                keyseq TEXT NOT NULL,
211                widget TEXT NOT NULL,
212                keymap TEXT NOT NULL DEFAULT 'main'
213            );
214
215            CREATE TABLE IF NOT EXISTS plugin_zstyles (
216                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
217                pattern TEXT NOT NULL,
218                style TEXT NOT NULL,
219                value TEXT NOT NULL
220            );
221
222            CREATE TABLE IF NOT EXISTS plugin_options (
223                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
224                name TEXT NOT NULL,
225                enabled INTEGER NOT NULL
226            );
227
228            CREATE TABLE IF NOT EXISTS plugin_autoloads (
229                plugin_id INTEGER NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
230                function TEXT NOT NULL,
231                flags TEXT NOT NULL DEFAULT ''
232            );
233
234            -- compaudit cache: security audit results per fpath directory
235            CREATE TABLE IF NOT EXISTS compaudit_cache (
236                id INTEGER PRIMARY KEY,
237                path TEXT NOT NULL UNIQUE,
238                mtime_secs INTEGER NOT NULL,
239                mtime_nsecs INTEGER NOT NULL,
240                uid INTEGER NOT NULL,
241                mode INTEGER NOT NULL,
242                is_secure INTEGER NOT NULL,
243                checked_at INTEGER NOT NULL
244            );
245
246            CREATE INDEX IF NOT EXISTS idx_plugins_path ON plugins(path);
247            CREATE INDEX IF NOT EXISTS idx_compaudit_path ON compaudit_cache(path);
248
249            -- Migration: legacy script_bytecode table (bytecode now lives in
250            -- the rkyv shard at ~/.zshrs/scripts.rkyv). Drop on open so
251            -- existing DBs reclaim the space and don't carry stale bytecode.
252            DROP INDEX IF EXISTS idx_script_bytecode_path;
253            DROP TABLE IF EXISTS script_bytecode;
254        "#,
255        )?;
256        // Migrate pre-binary_mtime DBs (column added 2026-05): the
257        // CREATE-IF-NOT-EXISTS above only adds the column for fresh
258        // dbs. ALTER TABLE on an existing db is a one-time no-op
259        // wrapped in an ignored-if-already-applied check. Mirrors the
260        // C analogue of zsh's $ZSH_VERSION-keyed compdump rebuild —
261        // any binary change invalidates the plugin replay shard so
262        // we don't replay deltas captured under the old runtime
263        // semantics. Without this, fixes to paramsubst / option
264        // handling don't take effect until the user manually
265        // `rm ~/.zshrs/plugins.db`.
266        let _ = self.conn.execute(
267            "ALTER TABLE plugins ADD COLUMN binary_mtime INTEGER NOT NULL DEFAULT 0",
268            [],
269        );
270        Ok(())
271    }
272
273    /// Check if a cached entry exists with matching mtime AND the
274    /// running zshrs binary's mtime is no newer than when the entry
275    /// was cached. Direct port of script_cache.rs's invalidation
276    /// logic (lines 188-194): any zshrs rebuild silently invalidates
277    /// plugin-cached deltas because runtime semantics may have
278    /// shifted (paramsubst flags, option aliases, builtin
279    /// resolution, …). Without this guard, a new build reads stale
280    /// deltas and replays them with the new engine — visible
281    /// regression where `zinit.zsh`'s `${ZINIT[BIN_DIR]}` returned
282    /// empty after re-source until the cache was manually cleared.
283    pub fn check(&self, path: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<i64> {
284        let row: Option<(i64, i64)> = self
285            .conn
286            .query_row(
287                "SELECT id, binary_mtime FROM plugins WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
288                params![path, mtime_secs, mtime_nsecs],
289                |row| Ok((row.get(0)?, row.get(1)?)),
290            )
291            .ok();
292        let (id, cached_bin_mtime) = row?;
293        if let Some(bin_mtime) = current_binary_mtime() {
294            if cached_bin_mtime < bin_mtime {
295                return None;
296            }
297        }
298        Some(id)
299    }
300
301    /// Load cached delta for a plugin by id.
302    pub fn load(&self, plugin_id: i64) -> rusqlite::Result<PluginDelta> {
303        let mut delta = PluginDelta::default();
304
305        // Functions (bincode-serialized AST blobs)
306        let mut stmt = self
307            .conn
308            .prepare("SELECT name, body FROM plugin_functions WHERE plugin_id = ?1")?;
309        let rows = stmt.query_map(params![plugin_id], |row| {
310            Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
311        })?;
312        for r in rows {
313            delta.functions.push(r?);
314        }
315
316        // Aliases
317        let mut stmt = self
318            .conn
319            .prepare("SELECT name, value, kind FROM plugin_aliases WHERE plugin_id = ?1")?;
320        let rows = stmt.query_map(params![plugin_id], |row| {
321            Ok((
322                row.get::<_, String>(0)?,
323                row.get::<_, String>(1)?,
324                AliasKind::from_i32(row.get::<_, i32>(2)?),
325            ))
326        })?;
327        for r in rows {
328            delta.aliases.push(r?);
329        }
330
331        // Variables
332        let mut stmt = self
333            .conn
334            .prepare("SELECT name, value, is_export FROM plugin_variables WHERE plugin_id = ?1")?;
335        let rows = stmt.query_map(params![plugin_id], |row| {
336            Ok((
337                row.get::<_, String>(0)?,
338                row.get::<_, String>(1)?,
339                row.get::<_, bool>(2)?,
340            ))
341        })?;
342        for r in rows {
343            let (name, value, is_export) = r?;
344            if is_export {
345                delta.exports.push((name, value));
346            } else {
347                delta.variables.push((name, value));
348            }
349        }
350
351        // Arrays
352        let mut stmt = self
353            .conn
354            .prepare("SELECT name, value_json FROM plugin_arrays WHERE plugin_id = ?1")?;
355        let rows = stmt.query_map(params![plugin_id], |row| {
356            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
357        })?;
358        for r in rows {
359            let (name, json) = r?;
360            // Simple JSON array: ["a","b","c"]
361            let vals: Vec<String> = json
362                .trim_matches(|c| c == '[' || c == ']')
363                .split(',')
364                .map(|s| s.trim().trim_matches('"').to_string())
365                .filter(|s| !s.is_empty())
366                .collect();
367            delta.arrays.push((name, vals));
368        }
369
370        // Associative arrays (key→value JSON object). Falls back to
371        // an empty map on parse failure rather than a load error so
372        // a malformed row doesn't break the whole replay path.
373        let mut stmt = self
374            .conn
375            .prepare("SELECT name, value_json FROM plugin_assoc_arrays WHERE plugin_id = ?1")?;
376        let rows = stmt.query_map(params![plugin_id], |row| {
377            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
378        })?;
379        for r in rows {
380            let (name, json) = r?;
381            let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or_default();
382            delta.assoc_arrays.push((name, map));
383        }
384
385        // Completions
386        let mut stmt = self
387            .conn
388            .prepare("SELECT command, function FROM plugin_completions WHERE plugin_id = ?1")?;
389        let rows = stmt.query_map(params![plugin_id], |row| {
390            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
391        })?;
392        for r in rows {
393            delta.completions.push(r?);
394        }
395
396        // Fpath
397        let mut stmt = self
398            .conn
399            .prepare("SELECT path FROM plugin_fpath WHERE plugin_id = ?1")?;
400        let rows = stmt.query_map(params![plugin_id], |row| row.get::<_, String>(0))?;
401        for r in rows {
402            delta.fpath_additions.push(r?);
403        }
404
405        // Hooks
406        let mut stmt = self
407            .conn
408            .prepare("SELECT hook, function FROM plugin_hooks WHERE plugin_id = ?1")?;
409        let rows = stmt.query_map(params![plugin_id], |row| {
410            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
411        })?;
412        for r in rows {
413            delta.hooks.push(r?);
414        }
415
416        // Bindkeys
417        let mut stmt = self
418            .conn
419            .prepare("SELECT keyseq, widget, keymap FROM plugin_bindkeys WHERE plugin_id = ?1")?;
420        let rows = stmt.query_map(params![plugin_id], |row| {
421            Ok((
422                row.get::<_, String>(0)?,
423                row.get::<_, String>(1)?,
424                row.get::<_, String>(2)?,
425            ))
426        })?;
427        for r in rows {
428            delta.bindkeys.push(r?);
429        }
430
431        // Zstyles
432        let mut stmt = self
433            .conn
434            .prepare("SELECT pattern, style, value FROM plugin_zstyles WHERE plugin_id = ?1")?;
435        let rows = stmt.query_map(params![plugin_id], |row| {
436            Ok((
437                row.get::<_, String>(0)?,
438                row.get::<_, String>(1)?,
439                row.get::<_, String>(2)?,
440            ))
441        })?;
442        for r in rows {
443            delta.zstyles.push(r?);
444        }
445
446        // Options
447        let mut stmt = self
448            .conn
449            .prepare("SELECT name, enabled FROM plugin_options WHERE plugin_id = ?1")?;
450        let rows = stmt.query_map(params![plugin_id], |row| {
451            Ok((row.get::<_, String>(0)?, row.get::<_, bool>(1)?))
452        })?;
453        for r in rows {
454            delta.options_changed.push(r?);
455        }
456
457        // Autoloads
458        let mut stmt = self
459            .conn
460            .prepare("SELECT function, flags FROM plugin_autoloads WHERE plugin_id = ?1")?;
461        let rows = stmt.query_map(params![plugin_id], |row| {
462            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
463        })?;
464        for r in rows {
465            delta.autoloads.push(r?);
466        }
467
468        Ok(delta)
469    }
470
471    /// Store a plugin delta. Replaces any existing entry for this path.
472    pub fn store(
473        &self,
474        path: &str,
475        mtime_secs: i64,
476        mtime_nsecs: i64,
477        source_time_ms: u64,
478        delta: &PluginDelta,
479    ) -> rusqlite::Result<()> {
480        let now = std::time::SystemTime::now()
481            .duration_since(std::time::UNIX_EPOCH)
482            .map(|d| d.as_secs() as i64)
483            .unwrap_or(0);
484
485        // Delete old entry if exists
486        self.conn
487            .execute("DELETE FROM plugins WHERE path = ?1", params![path])?;
488
489        let bin_mtime = current_binary_mtime().unwrap_or(0);
490        self.conn.execute(
491            "INSERT INTO plugins (path, mtime_secs, mtime_nsecs, source_time_ms, cached_at, binary_mtime) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
492            params![path, mtime_secs, mtime_nsecs, source_time_ms as i64, now, bin_mtime],
493        )?;
494        let plugin_id = self.conn.last_insert_rowid();
495
496        // Functions
497        for (name, body) in &delta.functions {
498            self.conn.execute(
499                "INSERT INTO plugin_functions (plugin_id, name, body) VALUES (?1, ?2, ?3)",
500                params![plugin_id, name, body],
501            )?;
502        }
503
504        // Aliases
505        for (name, value, kind) in &delta.aliases {
506            self.conn.execute(
507                "INSERT INTO plugin_aliases (plugin_id, name, value, kind) VALUES (?1, ?2, ?3, ?4)",
508                params![plugin_id, name, value, kind.as_i32()],
509            )?;
510        }
511
512        // Variables + exports
513        for (name, value) in &delta.variables {
514            self.conn.execute(
515                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 0)",
516                params![plugin_id, name, value],
517            )?;
518        }
519        for (name, value) in &delta.exports {
520            self.conn.execute(
521                "INSERT INTO plugin_variables (plugin_id, name, value, is_export) VALUES (?1, ?2, ?3, 1)",
522                params![plugin_id, name, value],
523            )?;
524        }
525
526        // Arrays
527        for (name, vals) in &delta.arrays {
528            let json = format!(
529                "[{}]",
530                vals.iter()
531                    .map(|v| format!("\"{}\"", v.replace('"', "\\\"")))
532                    .collect::<Vec<_>>()
533                    .join(",")
534            );
535            self.conn.execute(
536                "INSERT INTO plugin_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
537                params![plugin_id, name, json],
538            )?;
539        }
540
541        // Associative arrays — JSON-encode the key/value map. Use
542        // serde_json so quotes / backslashes / unicode round-trip
543        // correctly through the cache (the simple `["a","b"]`
544        // hand-format used for indexed arrays above doesn't escape
545        // properly for arbitrary-content keys/values).
546        for (name, map) in &delta.assoc_arrays {
547            let json = serde_json::to_string(map).unwrap_or_else(|_| "{}".to_string());
548            self.conn.execute(
549                "INSERT INTO plugin_assoc_arrays (plugin_id, name, value_json) VALUES (?1, ?2, ?3)",
550                params![plugin_id, name, json],
551            )?;
552        }
553
554        // Completions
555        for (cmd, func) in &delta.completions {
556            self.conn.execute(
557                "INSERT INTO plugin_completions (plugin_id, command, function) VALUES (?1, ?2, ?3)",
558                params![plugin_id, cmd, func],
559            )?;
560        }
561
562        // Fpath
563        for p in &delta.fpath_additions {
564            self.conn.execute(
565                "INSERT INTO plugin_fpath (plugin_id, path) VALUES (?1, ?2)",
566                params![plugin_id, p],
567            )?;
568        }
569
570        // Hooks
571        for (hook, func) in &delta.hooks {
572            self.conn.execute(
573                "INSERT INTO plugin_hooks (plugin_id, hook, function) VALUES (?1, ?2, ?3)",
574                params![plugin_id, hook, func],
575            )?;
576        }
577
578        // Bindkeys
579        for (keyseq, widget, keymap) in &delta.bindkeys {
580            self.conn.execute(
581                "INSERT INTO plugin_bindkeys (plugin_id, keyseq, widget, keymap) VALUES (?1, ?2, ?3, ?4)",
582                params![plugin_id, keyseq, widget, keymap],
583            )?;
584        }
585
586        // Zstyles
587        for (pattern, style, value) in &delta.zstyles {
588            self.conn.execute(
589                "INSERT INTO plugin_zstyles (plugin_id, pattern, style, value) VALUES (?1, ?2, ?3, ?4)",
590                params![plugin_id, pattern, style, value],
591            )?;
592        }
593
594        // Options
595        for (name, enabled) in &delta.options_changed {
596            self.conn.execute(
597                "INSERT INTO plugin_options (plugin_id, name, enabled) VALUES (?1, ?2, ?3)",
598                params![plugin_id, name, *enabled],
599            )?;
600        }
601
602        // Autoloads
603        for (func, flags) in &delta.autoloads {
604            self.conn.execute(
605                "INSERT INTO plugin_autoloads (plugin_id, function, flags) VALUES (?1, ?2, ?3)",
606                params![plugin_id, func, flags],
607            )?;
608        }
609
610        Ok(())
611    }
612
613    /// Stats for logging.
614    pub fn stats(&self) -> (i64, i64) {
615        let plugins: i64 = self
616            .conn
617            .query_row("SELECT COUNT(*) FROM plugins", [], |r| r.get(0))
618            .unwrap_or(0);
619        let functions: i64 = self
620            .conn
621            .query_row("SELECT COUNT(*) FROM plugin_functions", [], |r| r.get(0))
622            .unwrap_or(0);
623        (plugins, functions)
624    }
625
626    /// Count plugins whose file mtime no longer matches the cache.
627    pub fn count_stale(&self) -> usize {
628        let mut stmt = match self
629            .conn
630            .prepare("SELECT path, mtime_secs, mtime_nsecs FROM plugins")
631        {
632            Ok(s) => s,
633            Err(_) => return 0,
634        };
635        let rows = match stmt.query_map([], |row| {
636            Ok((
637                row.get::<_, String>(0)?,
638                row.get::<_, i64>(1)?,
639                row.get::<_, i64>(2)?,
640            ))
641        }) {
642            Ok(r) => r,
643            Err(_) => return 0,
644        };
645        let mut count = 0;
646        for (path, cached_s, cached_ns) in rows.flatten() {
647            match file_mtime(std::path::Path::new(&path)) {
648                Some((s, ns)) if s != cached_s || ns != cached_ns => count += 1,
649                None => count += 1, // file deleted
650                _ => {}
651            }
652        }
653        count
654    }
655
656    // -----------------------------------------------------------------
657    // compaudit cache — security audit results per fpath directory
658    // -----------------------------------------------------------------
659
660    /// Check if a directory's security audit result is cached and still valid.
661    /// Returns Some(is_secure) if cache hit, None if miss or stale.
662    pub fn check_compaudit(&self, dir: &str, mtime_secs: i64, mtime_nsecs: i64) -> Option<bool> {
663        self.conn.query_row(
664            "SELECT is_secure FROM compaudit_cache WHERE path = ?1 AND mtime_secs = ?2 AND mtime_nsecs = ?3",
665            params![dir, mtime_secs, mtime_nsecs],
666            |row| row.get::<_, bool>(0),
667        ).ok()
668    }
669
670    /// Store a compaudit result for a directory.
671    pub fn store_compaudit(
672        &self,
673        dir: &str,
674        mtime_secs: i64,
675        mtime_nsecs: i64,
676        uid: u32,
677        mode: u32,
678        is_secure: bool,
679    ) -> rusqlite::Result<()> {
680        let now = std::time::SystemTime::now()
681            .duration_since(std::time::UNIX_EPOCH)
682            .map(|d| d.as_secs() as i64)
683            .unwrap_or(0);
684
685        self.conn.execute(
686            "INSERT OR REPLACE INTO compaudit_cache (path, mtime_secs, mtime_nsecs, uid, mode, is_secure, checked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
687            params![dir, mtime_secs, mtime_nsecs, uid as i64, mode as i64, is_secure, now],
688        )?;
689        Ok(())
690    }
691
692    /// Run a full compaudit against fpath directories, using cache where valid.
693    /// Returns list of insecure directories (empty = all secure).
694    pub fn compaudit_cached(&self, fpath: &[std::path::PathBuf]) -> Vec<String> {
695        let euid = unsafe { libc::geteuid() };
696        let mut insecure = Vec::new();
697
698        for dir in fpath {
699            let dir_str = dir.to_string_lossy().to_string();
700            let meta = match std::fs::metadata(dir) {
701                Ok(m) => m,
702                Err(_) => continue, // dir doesn't exist, skip
703            };
704            let mt_s = meta.mtime();
705            let mt_ns = meta.mtime_nsec();
706
707            // Check cache first
708            if let Some(is_secure) = self.check_compaudit(&dir_str, mt_s, mt_ns) {
709                if !is_secure {
710                    insecure.push(dir_str);
711                }
712                continue;
713            }
714
715            // Cache miss — do the actual security check
716            let mode = meta.mode();
717            let uid = meta.uid();
718            let is_secure = Self::check_dir_security(&meta, euid);
719
720            // Also check parent directory
721            let parent_secure = dir
722                .parent()
723                .and_then(|p| std::fs::metadata(p).ok())
724                .map(|pm| Self::check_dir_security(&pm, euid))
725                .unwrap_or(true);
726
727            let secure = is_secure && parent_secure;
728
729            // Cache the result
730            let _ = self.store_compaudit(&dir_str, mt_s, mt_ns, uid, mode, secure);
731
732            if !secure {
733                insecure.push(dir_str);
734            }
735        }
736
737        if insecure.is_empty() {
738            tracing::debug!(
739                dirs = fpath.len(),
740                "compaudit: all directories secure (cached)"
741            );
742        } else {
743            tracing::warn!(
744                insecure_count = insecure.len(),
745                dirs = fpath.len(),
746                "compaudit: insecure directories found"
747            );
748        }
749
750        insecure
751    }
752
753    /// Enumerate every plugin currently in the `plugins` table.
754    /// Returns `(path, mtime_secs)` tuples in insertion order.
755    /// Used by `zshrs --dump-plugins` to feed the IntelliJ
756    /// External Libraries view.
757    pub fn list_plugin_paths(&self) -> Vec<(String, i64)> {
758        let mut stmt = match self
759            .conn
760            .prepare("SELECT path, mtime_secs FROM plugins ORDER BY id")
761        {
762            Ok(s) => s,
763            Err(_) => return Vec::new(),
764        };
765        let rows = match stmt.query_map([], |row| {
766            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
767        }) {
768            Ok(r) => r,
769            Err(_) => return Vec::new(),
770        };
771        rows.flatten().collect()
772    }
773
774    /// Check if a directory's permissions are secure.
775    /// Insecure = world-writable or group-writable AND not owned by root or EUID.
776    fn check_dir_security(meta: &std::fs::Metadata, euid: u32) -> bool {
777        let mode = meta.mode();
778        let uid = meta.uid();
779
780        // Owned by root or the current user — always OK
781        if uid == 0 || uid == euid {
782            return true;
783        }
784
785        // Not owned by us — check if world/group writable
786        let group_writable = mode & 0o020 != 0;
787        let world_writable = mode & 0o002 != 0;
788
789        !group_writable && !world_writable
790    }
791}
792
793/// Get mtime from file metadata as (secs, nsecs).
794pub fn file_mtime(path: &Path) -> Option<(i64, i64)> {
795    let meta = std::fs::metadata(path).ok()?;
796    Some((meta.mtime(), meta.mtime_nsec()))
797}
798
799/// One plugin entry as exposed to the IntelliJ External Libraries view.
800/// `manager` is the inferred plugin manager (zinit / oh-my-zsh / prezto /
801/// antidote / antigen / zplug / zsh-more-completions / zpwr / loose).
802/// `name` is the human-readable plugin identifier (`zsh-users/zsh-autosuggestions`,
803/// `git`, etc.). `root` is the absolute directory that holds the plugin's files.
804#[derive(Debug, Clone)]
805pub struct PluginEntry {
806    pub manager: String,
807    pub name: String,
808    pub root: PathBuf,
809}
810
811/// Classify a sourced plugin file path into `(manager, name, root_dir)`.
812/// The match order matters — first hit wins so `~/.oh-my-zsh/custom/plugins/foo`
813/// is "oh-my-zsh" not "loose".
814fn classify_plugin_path(path: &Path) -> PluginEntry {
815    let s = path.to_string_lossy();
816
817    // zinit: `<root>/plugins/<user>---<repo>/<file>` where root is either
818    // `~/.zinit` (legacy) or `$XDG_DATA_HOME/zinit` / `~/.local/share/zinit`.
819    for marker in ["/.zinit/plugins/", "/zinit/plugins/"] {
820        if let Some(start) = s.find(marker) {
821            let after = &s[start + marker.len()..];
822            if let Some(end) = after.find('/') {
823                let dir = &after[..end];
824                let name = dir.replacen("---", "/", 1);
825                let root: PathBuf = s[..start + marker.len() + end].into();
826                return PluginEntry { manager: "zinit".into(), name, root };
827            }
828        }
829    }
830
831    // oh-my-zsh: `~/.oh-my-zsh/{plugins,custom/plugins,themes,custom/themes}/<name>/<file>`
832    for (marker, kind) in [
833        ("/.oh-my-zsh/custom/plugins/", "plugin"),
834        ("/.oh-my-zsh/plugins/", "plugin"),
835        ("/.oh-my-zsh/custom/themes/", "theme"),
836        ("/.oh-my-zsh/themes/", "theme"),
837    ] {
838        if let Some(start) = s.find(marker) {
839            let after = &s[start + marker.len()..];
840            let end = after.find('/').unwrap_or(after.len());
841            let leaf = &after[..end];
842            let name = if kind == "theme" { format!("{}.theme", leaf) } else { leaf.to_string() };
843            let root: PathBuf = s[..start + marker.len() + end].into();
844            return PluginEntry { manager: "oh-my-zsh".into(), name, root };
845        }
846    }
847
848    // prezto: `~/.zprezto/modules/<name>/init.zsh`.
849    if let Some(start) = s.find("/.zprezto/modules/") {
850        let after = &s[start + "/.zprezto/modules/".len()..];
851        let end = after.find('/').unwrap_or(after.len());
852        let name = after[..end].to_string();
853        let root: PathBuf = s[..start + "/.zprezto/modules/".len() + end].into();
854        return PluginEntry { manager: "prezto".into(), name, root };
855    }
856
857    // antidote: `~/.cache/antidote/<user>/<repo>/<file>` or
858    // `~/.local/share/antidote/repos/<user>/<repo>/<file>`.
859    for marker in ["/antidote/repos/", "/.cache/antidote/"] {
860        if let Some(start) = s.find(marker) {
861            let after = &s[start + marker.len()..];
862            // user/repo — two path components.
863            let mut split = after.splitn(3, '/');
864            if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
865                let name = format!("{}/{}", user, repo);
866                let root: PathBuf = format!(
867                    "{}{}/{}",
868                    &s[..start + marker.len()],
869                    user,
870                    repo
871                )
872                .into();
873                return PluginEntry { manager: "antidote".into(), name, root };
874            }
875        }
876    }
877
878    // antigen: `~/.antigen/bundles/<user>/<repo>/<file>`.
879    if let Some(start) = s.find("/.antigen/bundles/") {
880        let after = &s[start + "/.antigen/bundles/".len()..];
881        let mut split = after.splitn(3, '/');
882        if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
883            let name = format!("{}/{}", user, repo);
884            let root: PathBuf = format!(
885                "{}/{}/{}",
886                &s[..start + "/.antigen/bundles".len()],
887                user,
888                repo
889            )
890            .into();
891            return PluginEntry { manager: "antigen".into(), name, root };
892        }
893    }
894
895    // zplug: `~/.zplug/repos/<user>/<repo>/<file>`.
896    if let Some(start) = s.find("/.zplug/repos/") {
897        let after = &s[start + "/.zplug/repos/".len()..];
898        let mut split = after.splitn(3, '/');
899        if let (Some(user), Some(repo), _) = (split.next(), split.next(), split.next()) {
900            let name = format!("{}/{}", user, repo);
901            let root: PathBuf = format!(
902                "{}/{}/{}",
903                &s[..start + "/.zplug/repos".len()],
904                user,
905                repo
906            )
907            .into();
908            return PluginEntry { manager: "zplug".into(), name, root };
909        }
910    }
911
912    // zsh-more-completions: the user's own 16k-file corpus. Group every
913    // file under one logical library so the IDE doesn't render 16k leaves.
914    if let Some(start) = s.find("/zsh-more-completions/") {
915        let root: PathBuf = s[..start + "/zsh-more-completions".len()].into();
916        return PluginEntry {
917            manager: "zsh-more-completions".into(),
918            name: "zsh-more-completions".into(),
919            root,
920        };
921    }
922
923    // zpwr: the user's CLI suite. One library, root = `$ZPWR` or `~/.zpwr`.
924    for marker in ["/.zpwr/", "/zpwr/"] {
925        if let Some(start) = s.find(marker) {
926            let root: PathBuf = s[..start + marker.len() - 1].into();
927            return PluginEntry {
928                manager: "zpwr".into(),
929                name: "zpwr".into(),
930                root,
931            };
932        }
933    }
934
935    // Loose: the file's parent directory is the root, basename is the name.
936    let root = path.parent().map(PathBuf::from).unwrap_or_else(|| path.into());
937    let name = root
938        .file_name()
939        .map(|n| n.to_string_lossy().into_owned())
940        .unwrap_or_else(|| "(loose)".into());
941    PluginEntry { manager: "loose".into(), name, root }
942}
943
944/// Read every entry in `plugins` and group by `(manager, name, root)`.
945/// Returns one `PluginEntry` per unique plugin (de-duplicated across the
946/// many files a single plugin typically sources).
947pub fn list_plugins(cache_path: &Path) -> Vec<PluginEntry> {
948    let cache = match PluginCache::open(cache_path) {
949        Ok(c) => c,
950        Err(_) => return Vec::new(),
951    };
952    let mut seen: std::collections::BTreeMap<(String, String, PathBuf), PluginEntry> =
953        std::collections::BTreeMap::new();
954    for (path, _mtime) in cache.list_plugin_paths() {
955        let entry = classify_plugin_path(Path::new(&path));
956        seen.entry((entry.manager.clone(), entry.name.clone(), entry.root.clone()))
957            .or_insert(entry);
958    }
959    seen.into_values().collect()
960}
961
962/// JSON consumed by the IntelliJ `AdditionalLibraryRootsProvider`.
963/// Schema:
964/// ```json
965/// {
966///   "schema": 1,
967///   "plugins": [
968///     {"manager": "zinit", "name": "zsh-users/zsh-autosuggestions",
969///      "root": "/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions"}
970///   ]
971/// }
972/// ```
973/// Manager+name uniquely identify a plugin; root is the directory to
974/// expose as a synthetic library root.
975pub fn dump_plugins_json() -> String {
976    let entries = list_plugins(&default_cache_path());
977    let mut s = String::from("{\"schema\":1,\"plugins\":[");
978    for (i, e) in entries.iter().enumerate() {
979        if i > 0 { s.push(','); }
980        s.push_str(&format!(
981            "{{\"manager\":{},\"name\":{},\"root\":{}}}",
982            json_str(&e.manager),
983            json_str(&e.name),
984            json_str(&e.root.to_string_lossy())
985        ));
986    }
987    s.push_str("]}");
988    s
989}
990
991fn json_str(s: &str) -> String {
992    let mut out = String::with_capacity(s.len() + 2);
993    out.push('"');
994    for c in s.chars() {
995        match c {
996            '"' => out.push_str("\\\""),
997            '\\' => out.push_str("\\\\"),
998            '\n' => out.push_str("\\n"),
999            '\r' => out.push_str("\\r"),
1000            '\t' => out.push_str("\\t"),
1001            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1002            c => out.push(c),
1003        }
1004    }
1005    out.push('"');
1006    out
1007}
1008
1009/// Default path for the plugin cache db. Honors $ZSHRS_HOME so the
1010/// shell agrees with the daemon on where state lives.
1011pub fn default_cache_path() -> PathBuf {
1012    if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
1013        return PathBuf::from(custom).join("plugins.db");
1014    }
1015    dirs::home_dir()
1016        .unwrap_or_else(|| PathBuf::from("/tmp"))
1017        .join(".zshrs/plugins.db")
1018}
1019
1020#[cfg(test)]
1021mod migration_tests {
1022    use super::*;
1023
1024    #[test]
1025    fn opening_an_existing_db_drops_legacy_script_bytecode_table() {
1026        let _g = crate::test_util::global_state_lock();
1027        // Simulate a pre-migration DB: open with an old schema that still
1028        // had script_bytecode, insert a row, close, then re-open via the
1029        // current `PluginCache::open` path. The migration in `init_schema`
1030        // must leave the table gone so SQLite holds zero bytecode bytes.
1031        let tmp = tempfile::tempdir().unwrap();
1032        let db_path = tmp.path().join("legacy.db");
1033
1034        // Hand-build the legacy table.
1035        let pre = Connection::open(&db_path).unwrap();
1036        pre.execute_batch(
1037            r#"
1038            CREATE TABLE script_bytecode (
1039                id INTEGER PRIMARY KEY,
1040                path TEXT NOT NULL UNIQUE,
1041                mtime_secs INTEGER NOT NULL,
1042                mtime_nsecs INTEGER NOT NULL,
1043                bytecode BLOB NOT NULL,
1044                cached_at INTEGER NOT NULL
1045            );
1046            CREATE INDEX idx_script_bytecode_path ON script_bytecode(path);
1047            INSERT INTO script_bytecode (id, path, mtime_secs, mtime_nsecs, bytecode, cached_at)
1048                VALUES (1, '/fake/legacy.zsh', 0, 0, x'00deadbeef', 0);
1049            "#,
1050        )
1051        .unwrap();
1052        drop(pre);
1053
1054        // Re-open via the production path — migration runs.
1055        let _cache = PluginCache::open(&db_path).expect("open after migration");
1056
1057        // Confirm script_bytecode is gone.
1058        let post = Connection::open(&db_path).unwrap();
1059        let exists: i64 = post
1060            .query_row(
1061                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='script_bytecode'",
1062                [],
1063                |row| row.get(0),
1064            )
1065            .unwrap();
1066        assert_eq!(exists, 0, "legacy script_bytecode must be dropped");
1067    }
1068
1069    // ========================================================
1070    // default_cache_path — ZSHRS_HOME precedence
1071    // ========================================================
1072
1073    fn with_zshrs_home<F: FnOnce()>(value: Option<&str>, f: F) {
1074        let prev = std::env::var_os("ZSHRS_HOME");
1075        match value {
1076            Some(v) => std::env::set_var("ZSHRS_HOME", v),
1077            None => std::env::remove_var("ZSHRS_HOME"),
1078        }
1079        f();
1080        match prev {
1081            Some(v) => std::env::set_var("ZSHRS_HOME", v),
1082            None => std::env::remove_var("ZSHRS_HOME"),
1083        }
1084    }
1085
1086    #[test]
1087    fn default_cache_path_honors_zshrs_home() {
1088        let _g = crate::test_util::global_state_lock();
1089        with_zshrs_home(Some("/tmp/zshrs-plugin-cache-home"), || {
1090            assert_eq!(
1091                default_cache_path(),
1092                PathBuf::from("/tmp/zshrs-plugin-cache-home/plugins.db")
1093            );
1094        });
1095    }
1096
1097    #[test]
1098    fn default_cache_path_filename_is_plugins_db() {
1099        let _g = crate::test_util::global_state_lock();
1100        with_zshrs_home(Some("/tmp/zshrs-plugin-fname"), || {
1101            assert_eq!(
1102                default_cache_path().file_name().and_then(|s| s.to_str()),
1103                Some("plugins.db")
1104            );
1105        });
1106    }
1107
1108    #[test]
1109    fn default_cache_path_falls_back_to_home_dot_zshrs() {
1110        let _g = crate::test_util::global_state_lock();
1111        with_zshrs_home(None, || {
1112            let p = default_cache_path();
1113            // Path must end in `.zshrs/plugins.db` regardless of HOME.
1114            let s = p.to_string_lossy();
1115            assert!(
1116                s.ends_with(".zshrs/plugins.db"),
1117                "expected .zshrs/plugins.db tail, got: {}",
1118                s
1119            );
1120        });
1121    }
1122
1123    #[test]
1124    fn default_cache_path_uses_distinct_dir_per_zshrs_home_change() {
1125        let _g = crate::test_util::global_state_lock();
1126        with_zshrs_home(Some("/tmp/zshrs-plugin-a"), || {
1127            let a = default_cache_path();
1128            with_zshrs_home(Some("/tmp/zshrs-plugin-b"), || {
1129                let b = default_cache_path();
1130                assert_ne!(a, b, "different ZSHRS_HOME must yield different paths");
1131            });
1132        });
1133    }
1134
1135    // ========================================================
1136    // file_mtime — metadata sniff
1137    // ========================================================
1138
1139    #[test]
1140    fn file_mtime_returns_some_for_existing_file() {
1141        let _g = crate::test_util::global_state_lock();
1142        let tmp = std::env::temp_dir().join("zshrs_plugin_cache_mtime.txt");
1143        std::fs::write(&tmp, b"x").unwrap();
1144        let mt = file_mtime(&tmp);
1145        assert!(mt.is_some(), "existing file should produce mtime");
1146        // Seconds since epoch is positive on any sane system clock.
1147        let (secs, _ns) = mt.unwrap();
1148        assert!(secs > 0, "mtime secs must be positive: {}", secs);
1149        let _ = std::fs::remove_file(&tmp);
1150    }
1151
1152    #[test]
1153    fn file_mtime_returns_none_for_missing_path() {
1154        let _g = crate::test_util::global_state_lock();
1155        assert!(file_mtime(Path::new("/nonexistent/zshrs/missing.bin")).is_none());
1156    }
1157
1158    #[test]
1159    fn file_mtime_secs_monotonic_after_rewrite() {
1160        let _g = crate::test_util::global_state_lock();
1161        let tmp = std::env::temp_dir().join("zshrs_plugin_cache_mtime_two.txt");
1162        std::fs::write(&tmp, b"a").unwrap();
1163        let first = file_mtime(&tmp).unwrap();
1164        // Sleep slightly to ensure mtime resolution boundary.
1165        std::thread::sleep(std::time::Duration::from_millis(1100));
1166        std::fs::write(&tmp, b"b").unwrap();
1167        let second = file_mtime(&tmp).unwrap();
1168        // Second mtime >= first mtime (clocks don't go backwards
1169        // on any unit-test host we care about).
1170        assert!(
1171            second >= first,
1172            "mtime regressed: first={:?} second={:?}",
1173            first,
1174            second
1175        );
1176        let _ = std::fs::remove_file(&tmp);
1177    }
1178
1179    #[test]
1180    fn file_mtime_path_with_special_chars_resolves() {
1181        let _g = crate::test_util::global_state_lock();
1182        let tmp = std::env::temp_dir().join("zshrs plugin cache (space).bin");
1183        std::fs::write(&tmp, b"x").unwrap();
1184        let mt = file_mtime(&tmp);
1185        assert!(mt.is_some(), "spaces in filename must not block resolution");
1186        let _ = std::fs::remove_file(&tmp);
1187    }
1188
1189    #[test]
1190    fn default_cache_path_relative_zshrs_home_taken_verbatim() {
1191        let _g = crate::test_util::global_state_lock();
1192        with_zshrs_home(Some("relative-dir"), || {
1193            assert_eq!(
1194                default_cache_path(),
1195                PathBuf::from("relative-dir/plugins.db")
1196            );
1197        });
1198    }
1199
1200    #[test]
1201    fn default_cache_path_empty_zshrs_home_is_empty_dir_plus_db() {
1202        let _g = crate::test_util::global_state_lock();
1203        with_zshrs_home(Some(""), || {
1204            // env::var_os("") returns Some("") — code takes the
1205            // override branch and joins "" + "plugins.db" = "plugins.db".
1206            assert_eq!(default_cache_path(), PathBuf::from("plugins.db"));
1207        });
1208    }
1209}
1210
1211#[cfg(test)]
1212mod classify_tests {
1213    use super::*;
1214    use std::path::Path;
1215
1216    fn classify(p: &str) -> (String, String, String) {
1217        let e = classify_plugin_path(Path::new(p));
1218        (e.manager, e.name, e.root.to_string_lossy().into_owned())
1219    }
1220
1221    #[test]
1222    fn zinit_legacy_dir_user_repo() {
1223        let (m, n, r) = classify(
1224            "/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions/zsh-autosuggestions.plugin.zsh",
1225        );
1226        assert_eq!(m, "zinit");
1227        assert_eq!(n, "zsh-users/zsh-autosuggestions");
1228        assert_eq!(
1229            r,
1230            "/Users/wizard/.zinit/plugins/zsh-users---zsh-autosuggestions"
1231        );
1232    }
1233
1234    #[test]
1235    fn zinit_xdg_dir_user_repo() {
1236        let (m, n, _) = classify(
1237            "/home/u/.local/share/zinit/plugins/romkatv---powerlevel10k/p10k.zsh",
1238        );
1239        assert_eq!(m, "zinit");
1240        assert_eq!(n, "romkatv/powerlevel10k");
1241    }
1242
1243    #[test]
1244    fn oh_my_zsh_core_plugin() {
1245        let (m, n, r) = classify("/Users/wizard/.oh-my-zsh/plugins/git/git.plugin.zsh");
1246        assert_eq!(m, "oh-my-zsh");
1247        assert_eq!(n, "git");
1248        assert_eq!(r, "/Users/wizard/.oh-my-zsh/plugins/git");
1249    }
1250
1251    #[test]
1252    fn oh_my_zsh_custom_plugin() {
1253        let (m, n, _) = classify(
1254            "/Users/wizard/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh",
1255        );
1256        assert_eq!(m, "oh-my-zsh");
1257        assert_eq!(n, "zsh-syntax-highlighting");
1258    }
1259
1260    #[test]
1261    fn oh_my_zsh_theme_tagged_with_theme_suffix() {
1262        let (m, n, _) =
1263            classify("/Users/wizard/.oh-my-zsh/themes/agnoster.zsh-theme");
1264        assert_eq!(m, "oh-my-zsh");
1265        assert_eq!(n, "agnoster.zsh-theme.theme");
1266    }
1267
1268    #[test]
1269    fn prezto_module() {
1270        let (m, n, _) = classify("/Users/wizard/.zprezto/modules/git/init.zsh");
1271        assert_eq!(m, "prezto");
1272        assert_eq!(n, "git");
1273    }
1274
1275    #[test]
1276    fn antidote_repo() {
1277        let (m, n, _) = classify(
1278            "/Users/wizard/.cache/antidote/zsh-users/zsh-autosuggestions/zsh-autosuggestions.zsh",
1279        );
1280        assert_eq!(m, "antidote");
1281        assert_eq!(n, "zsh-users/zsh-autosuggestions");
1282    }
1283
1284    #[test]
1285    fn antigen_bundle() {
1286        let (m, n, _) = classify(
1287            "/Users/wizard/.antigen/bundles/zsh-users/zsh-completions/zsh-completions.plugin.zsh",
1288        );
1289        assert_eq!(m, "antigen");
1290        assert_eq!(n, "zsh-users/zsh-completions");
1291    }
1292
1293    #[test]
1294    fn zplug_repo() {
1295        let (m, n, _) = classify(
1296            "/Users/wizard/.zplug/repos/zsh-users/zsh-history-substring-search/zsh-history-substring-search.zsh",
1297        );
1298        assert_eq!(m, "zplug");
1299        assert_eq!(n, "zsh-users/zsh-history-substring-search");
1300    }
1301
1302    #[test]
1303    fn zsh_more_completions_groups_into_one() {
1304        let (m, n, _) = classify(
1305            "/Users/wizard/forkedRepos/zsh-more-completions/src/_some_long_completion",
1306        );
1307        assert_eq!(m, "zsh-more-completions");
1308        assert_eq!(n, "zsh-more-completions");
1309    }
1310
1311    #[test]
1312    fn zpwr_root_recognized() {
1313        let (m, n, _) = classify("/Users/wizard/.zpwr/local/.aliases.sh");
1314        assert_eq!(m, "zpwr");
1315        assert_eq!(n, "zpwr");
1316    }
1317
1318    #[test]
1319    fn loose_plugin_uses_parent_dir_as_name() {
1320        let (m, n, r) = classify("/opt/local/share/zsh/something/init.zsh");
1321        assert_eq!(m, "loose");
1322        assert_eq!(n, "something");
1323        assert_eq!(r, "/opt/local/share/zsh/something");
1324    }
1325}
1326
1327// ===========================================================
1328// Methods moved verbatim from src/ported/vm_helper because their
1329// C counterpart's source file maps 1:1 to this Rust module.
1330// Phase: drift
1331// ===========================================================
1332
1333// BEGIN moved-from-exec-rs
1334impl crate::ported::vm_helper::ShellExecutor {
1335    /// Snapshot executor state before sourcing a plugin (for delta computation).
1336    pub(crate) fn snapshot_state(&self) -> PluginSnapshot {
1337        PluginSnapshot {
1338            functions: self.function_names().into_iter().collect(),
1339            aliases: self.alias_entries().into_iter().map(|(k, _)| k).collect(),
1340            global_aliases: self
1341                .global_alias_entries()
1342                .into_iter()
1343                .map(|(k, _)| k)
1344                .collect(),
1345            suffix_aliases: self
1346                .suffix_alias_entries()
1347                .into_iter()
1348                .map(|(k, _)| k)
1349                .collect(),
1350            variables: if let Ok(tab) = crate::ported::params::paramtab().read() {
1351                tab.iter()
1352                    .filter(|(_, pm)| pm.u_arr.is_none())
1353                    .map(|(k, pm)| (k.clone(), pm.u_str.clone().unwrap_or_default()))
1354                    .collect()
1355            } else {
1356                std::collections::HashMap::new()
1357            },
1358            arrays: if let Ok(tab) = crate::ported::params::paramtab().read() {
1359                tab.iter()
1360                    .filter(|(_, pm)| pm.u_arr.is_some())
1361                    .map(|(k, _)| k.clone())
1362                    .collect()
1363            } else {
1364                std::collections::HashSet::new()
1365            },
1366            assoc_arrays: if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
1367                m.keys().cloned().collect()
1368            } else {
1369                std::collections::HashSet::new()
1370            },
1371            fpath: self.fpath.clone(),
1372            options: crate::ported::options::opt_state_snapshot(),
1373            hooks: {
1374                // Snapshot `<hook>_functions` arrays from canonical paramtab.
1375                let names = [
1376                    "chpwd",
1377                    "precmd",
1378                    "preexec",
1379                    "periodic",
1380                    "zshexit",
1381                    "zshaddhistory",
1382                ];
1383                let mut m = std::collections::HashMap::new();
1384                for h in &names {
1385                    let arr_name = format!("{}_functions", h);
1386                    if let Some(arr) = self.array(&arr_name) {
1387                        if !arr.is_empty() {
1388                            m.insert(h.to_string(), arr);
1389                        }
1390                    }
1391                }
1392                m
1393            },
1394            autoloads: {
1395                // Walk canonical shfunctab for autoload-pending entries
1396                // (PM_UNDEFINED set). Snapshot is keyed by function name.
1397                crate::ported::hashtable::shfunctab_lock()
1398                    .read()
1399                    .ok()
1400                    .map(|t| {
1401                        t.iter()
1402                            .filter(|(_, shf)| (shf.node.flags as u32 & PM_UNDEFINED) != 0)
1403                            .map(|(name, _)| name.clone())
1404                            .collect()
1405                    })
1406                    .unwrap_or_default()
1407            },
1408        }
1409    }
1410    /// Compute the delta between current state and a previous snapshot.
1411    pub(crate) fn diff_state(&self, snap: &PluginSnapshot) -> crate::plugin_cache::PluginDelta {
1412        let mut delta = PluginDelta::default();
1413
1414        // Walk every HashMap in sorted-key order so the resulting
1415        // PluginDelta serializes byte-identically across runs of an
1416        // identical state. Without sorting, rkyv-encoded delta blobs
1417        // differ run-to-run, defeating cache reuse and tripping
1418        // diff-based snapshot tests.
1419
1420        // New functions — serialize canonical source text (UTF-8 bytes)
1421        // for instant replay. Replay parses + compiles via the new pipeline.
1422        let mut fn_keys: Vec<&String> = self.function_source.keys().collect();
1423        fn_keys.sort();
1424        for name in fn_keys {
1425            if !snap.functions.contains(name) {
1426                let source = self.function_source.get(name).unwrap();
1427                delta
1428                    .functions
1429                    .push((name.clone(), source.as_bytes().to_vec()));
1430            }
1431        }
1432
1433        let push_alias = |delta: &mut PluginDelta,
1434                          entries: Vec<(String, String)>,
1435                          snap_set: &std::collections::HashSet<String>,
1436                          kind: AliasKind| {
1437            let mut entries = entries;
1438            entries.sort_by(|a, b| a.0.cmp(&b.0));
1439            for (name, value) in entries {
1440                if !snap_set.contains(&name) {
1441                    delta.aliases.push((name, value, kind));
1442                }
1443            }
1444        };
1445        push_alias(
1446            &mut delta,
1447            self.alias_entries(),
1448            &snap.aliases,
1449            AliasKind::Regular,
1450        );
1451        push_alias(
1452            &mut delta,
1453            self.global_alias_entries(),
1454            &snap.global_aliases,
1455            AliasKind::Global,
1456        );
1457        push_alias(
1458            &mut delta,
1459            self.suffix_alias_entries(),
1460            &snap.suffix_aliases,
1461            AliasKind::Suffix,
1462        );
1463
1464        // New/changed variables. Skip shell-special parameters whose
1465        // values are runtime-state, not script-state — replaying them
1466        // poisons subsequent shells with values frozen from the
1467        // capture run. C zsh maintains these per-process and never
1468        // serializes them: `_` (last argv of last command, Src/init.c
1469        // special_params; gets `/tmp/foo` from a prior bash test then
1470        // gets fed into `(( $_ ))` math in a user's .zshrc), `?`
1471        // (last exit), `$`/`!`/`PPID` (process IDs), `RANDOM`,
1472        // `SECONDS`, `EPOCHSECONDS`, `LINENO`, `OLDPWD`, `PWD`
1473        // (volatile; cwd is re-read on replay anyway), `STATUS`,
1474        // `OPTIND`, `IFS` (must default to whitespace at shell
1475        // startup unless user explicitly sets it). Direct port of
1476        // the C analogue's PM_SPECIAL flag — those params don't
1477        // round-trip through the parameter-table dump path.
1478        const NON_REPLAYABLE_VARS: &[&str] = &[
1479            "0",
1480            "_",
1481            "?",
1482            "$",
1483            "!",
1484            "PPID",
1485            "RANDOM",
1486            "SECONDS",
1487            "EPOCHSECONDS",
1488            "EPOCHREALTIME",
1489            "LINENO",
1490            "OLDPWD",
1491            "PWD",
1492            "STATUS",
1493            "OPTIND",
1494            "OPTARG",
1495            "IFS",
1496            "FUNCNAME",
1497            "BASHPID",
1498            "BASH_LINENO",
1499            "BASH_SOURCE",
1500            "ZSH_ARGZERO",
1501            "ZSH_EVAL_CONTEXT",
1502            "ZSH_SUBSHELL",
1503            "HISTCMD",
1504            "MATCH",
1505            "MBEGIN",
1506            "MEND",
1507        ];
1508        let mut var_keys: Vec<String> = if let Ok(tab) = crate::ported::params::paramtab().read() {
1509            tab.iter()
1510                .filter(|(_, pm)| pm.u_arr.is_none())
1511                .map(|(k, _)| k.clone())
1512                .collect()
1513        } else {
1514            Vec::new()
1515        };
1516        var_keys.sort();
1517        for name in &var_keys {
1518            if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
1519                continue;
1520            }
1521            let value = crate::ported::params::getsparam(name).unwrap_or_default();
1522            match snap.variables.get(name) {
1523                Some(old) if old == &value => {} // unchanged
1524                _ => {
1525                    // Check if it's also exported
1526                    if env::var(name).ok().as_ref() == Some(&value) {
1527                        delta.exports.push((name.clone(), value.clone()));
1528                    } else {
1529                        delta.variables.push((name.clone(), value.clone()));
1530                    }
1531                }
1532            }
1533        }
1534
1535        // New arrays — iterate paramtab for PM_ARRAY entries.
1536        let arr_entries: Vec<(String, Vec<String>)> =
1537            if let Ok(tab) = crate::ported::params::paramtab().read() {
1538                let mut v: Vec<(String, Vec<String>)> = tab
1539                    .iter()
1540                    .filter_map(|(k, pm)| pm.u_arr.clone().map(|a| (k.clone(), a)))
1541                    .collect();
1542                v.sort_by(|a, b| a.0.cmp(&b.0));
1543                v
1544            } else {
1545                Vec::new()
1546            };
1547        for (name, values) in arr_entries {
1548            if !snap.arrays.contains(&name) {
1549                delta.arrays.push((name, values));
1550            }
1551        }
1552
1553        // New / changed associative arrays. zinit creates `ZINIT[…]`
1554        // entries during sourcing; without this capture, the cache
1555        // replay path saw an empty ZINIT and `${ZINIT[BIN_DIR]}`
1556        // returned "" on every subsequent shell start. Direct port of
1557        // zsh's plugin-replay model — assoc deltas are first-class
1558        // captures alongside scalars and arrays.
1559        let assoc_entries: Vec<(String, indexmap::IndexMap<String, String>)> =
1560            if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
1561                let mut v: Vec<(String, indexmap::IndexMap<String, String>)> =
1562                    m.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1563                v.sort_by(|a, b| a.0.cmp(&b.0));
1564                v
1565            } else {
1566                Vec::new()
1567            };
1568        for (name, map) in assoc_entries {
1569            if !snap.assoc_arrays.contains(&name) {
1570                // Executor's assoc storage is IndexMap (insertion-
1571                // ordered, required by `(kv)` etc.). The plugin_cache
1572                // delta uses a plain HashMap since the cache replay
1573                // reseeds the assoc and order is reconstructed by
1574                // the script's own typeset ordering. Convert here.
1575                let plain: HashMap<String, String> =
1576                    map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
1577                delta.assoc_arrays.push((name, plain));
1578            }
1579        }
1580
1581        // New fpath entries
1582        for p in &self.fpath {
1583            if !snap.fpath.contains(p) {
1584                delta.fpath_additions.push(p.to_string_lossy().to_string());
1585            }
1586        }
1587
1588        // Changed options — diff against canonical OPTS_LIVE snapshot.
1589        let current = crate::ported::options::opt_state_snapshot();
1590        let mut opt_keys: Vec<&String> = current.keys().collect();
1591        opt_keys.sort();
1592        for name in opt_keys {
1593            let value = current.get(name).unwrap();
1594            match snap.options.get(name) {
1595                Some(old) if old == value => {}
1596                _ => delta.options_changed.push((name.clone(), *value)),
1597            }
1598        }
1599
1600        // New hooks — read from canonical `<hook>_functions` arrays.
1601        let names = [
1602            "chpwd",
1603            "precmd",
1604            "preexec",
1605            "periodic",
1606            "zshexit",
1607            "zshaddhistory",
1608        ];
1609        let mut hook_names: Vec<&&str> = names.iter().collect();
1610        hook_names.sort();
1611        for &h in hook_names {
1612            let arr_name = format!("{}_functions", h);
1613            let funcs = self.array(&arr_name).unwrap_or_default();
1614            let old_funcs = snap.hooks.get(h);
1615            for f in &funcs {
1616                let is_new = old_funcs.is_none_or(|old| !old.contains(f));
1617                if is_new {
1618                    delta.hooks.push((h.to_string(), f.clone()));
1619                }
1620            }
1621        }
1622
1623        // New autoloads — read PM_UNDEFINED entries from canonical shfunctab.
1624        let current_autoloads: Vec<String> = crate::ported::hashtable::shfunctab_lock()
1625            .read()
1626            .ok()
1627            .map(|t| {
1628                t.iter()
1629                    .filter(|(_, shf)| (shf.node.flags as u32 & PM_UNDEFINED) != 0)
1630                    .map(|(name, _)| name.clone())
1631                    .collect()
1632            })
1633            .unwrap_or_default();
1634        let mut autoload_keys: Vec<&String> = current_autoloads.iter().collect();
1635        autoload_keys.sort();
1636        for name in autoload_keys {
1637            if !snap.autoloads.contains(name) {
1638                // Flags stub-string: canonical autoload sets only PM_UNDEFINED
1639                // (the -U/-z/-k/-t/-d details were never consumed by replay).
1640                delta.autoloads.push((name.clone(), String::new()));
1641            }
1642        }
1643
1644        delta
1645    }
1646    /// Replay a cached plugin delta into the executor state.
1647    pub(crate) fn replay_plugin_delta(&mut self, delta: &crate::plugin_cache::PluginDelta) {
1648        // Aliases
1649        for (name, value, kind) in &delta.aliases {
1650            match kind {
1651                AliasKind::Regular => {
1652                    self.set_alias(name.clone(), value.clone());
1653                }
1654                AliasKind::Global => {
1655                    self.set_global_alias(name.clone(), value.clone());
1656                }
1657                AliasKind::Suffix => {
1658                    self.set_suffix_alias(name.clone(), value.clone());
1659                }
1660            }
1661        }
1662
1663        // Variables. Drop shell-special parameters even on the
1664        // replay side — pre-existing caches from before the
1665        // diff_state filter was added still contain entries for
1666        // `_`, `PPID`, etc.; replaying them poisons the new shell.
1667        // Keeping the same exclusion list as `diff_state` so old
1668        // caches self-heal on next read.
1669        const NON_REPLAYABLE_VARS: &[&str] = &[
1670            "0",
1671            "_",
1672            "?",
1673            "$",
1674            "!",
1675            "PPID",
1676            "RANDOM",
1677            "SECONDS",
1678            "EPOCHSECONDS",
1679            "EPOCHREALTIME",
1680            "LINENO",
1681            "OLDPWD",
1682            "PWD",
1683            "STATUS",
1684            "OPTIND",
1685            "OPTARG",
1686            "IFS",
1687            "FUNCNAME",
1688            "BASHPID",
1689            "BASH_LINENO",
1690            "BASH_SOURCE",
1691            "ZSH_ARGZERO",
1692            "ZSH_EVAL_CONTEXT",
1693            "ZSH_SUBSHELL",
1694            "HISTCMD",
1695            "MATCH",
1696            "MBEGIN",
1697            "MEND",
1698        ];
1699        for (name, value) in &delta.variables {
1700            if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
1701                continue;
1702            }
1703            self.set_scalar(name.clone(), value.clone());
1704        }
1705
1706        // Exports (set in both variables and process env)
1707        for (name, value) in &delta.exports {
1708            if NON_REPLAYABLE_VARS.contains(&name.as_str()) {
1709                continue;
1710            }
1711            self.set_scalar(name.clone(), value.clone());
1712            env::set_var(name, value);
1713        }
1714
1715        // Arrays
1716        for (name, values) in &delta.arrays {
1717            self.set_array(name.clone(), values.clone());
1718        }
1719
1720        // Associative arrays — restore plugin-defined assocs (e.g.
1721        // ZINIT, ZINIT_SNIPPETS, ZINIT_REPORTS) so subsequent shells
1722        // see the same `${ZINIT[BIN_DIR]}` etc. that the original
1723        // sourcing established. Mirrors the diff_state capture above.
1724        for (name, map) in &delta.assoc_arrays {
1725            // Plugin cache uses HashMap; executor uses IndexMap.
1726            // Reseed by inserting key-by-key so the IndexMap variant
1727            // is constructed without needing a HashMap→IndexMap
1728            // From impl that may not be available.
1729            let mut idx_map: indexmap::IndexMap<String, String> =
1730                indexmap::IndexMap::with_capacity(map.len());
1731            // Sort for deterministic order (the diff_state stored
1732            // a HashMap which has no defined order; the original
1733            // insertion order was lost). Sort is the simplest
1734            // reproducible choice — matches `(o)`-flag default.
1735            let mut entries: Vec<(&String, &String)> = map.iter().collect();
1736            entries.sort_by(|a, b| a.0.cmp(b.0));
1737            for (k, v) in entries {
1738                idx_map.insert(k.clone(), v.clone());
1739            }
1740            self.set_assoc(name.clone(), idx_map);
1741        }
1742
1743        // Fpath additions
1744        for p in &delta.fpath_additions {
1745            let pb = PathBuf::from(p);
1746            if !self.fpath.contains(&pb) {
1747                self.fpath.push(pb);
1748            }
1749        }
1750
1751        // Completions
1752        if !delta.completions.is_empty() {
1753            let mut comps = self.assoc("_comps").unwrap_or_default();
1754            for (cmd, func) in &delta.completions {
1755                comps.insert(cmd.clone(), func.clone());
1756            }
1757            self.set_assoc("_comps".to_string(), comps);
1758        }
1759
1760        // Options — write into canonical OPTS_LIVE.
1761        for (name, enabled) in &delta.options_changed {
1762            crate::ported::options::opt_state_set(name, *enabled);
1763        }
1764
1765        // Hooks — append into the canonical `<hook>_functions`
1766        // paramtab array (port of `Src/Functions/Misc/add-zsh-hook`
1767        // shell-function idiom). NOT the C-module HOOKTAB
1768        // (src/ported/module.rs `addhookfunc`), which stores
1769        // Hookfn fn pointers for C-internal hookdefs.
1770        for (hook, func) in &delta.hooks {
1771            let array_name = format!("{}_functions", hook);
1772            let mut arr = self.array(&array_name).unwrap_or_default();
1773            if !arr.iter().any(|f| f == func) {
1774                arr.push(func.clone());
1775                crate::ported::params::setaparam(&array_name, arr);
1776            }
1777        }
1778
1779        // Plugin cache replay: each bincode blob is a ShellCommand AST.
1780        // Replay each function's source text through parse_init + parse + ZshCompiler.
1781        // Delta format: name → UTF-8 source bytes (no AST round-trip needed).
1782        for (name, bytes) in &delta.functions {
1783            let Ok(source) = std::str::from_utf8(bytes) else {
1784                continue;
1785            };
1786            // Mirror Src/init.c errflag save/clear/check around parse.
1787            let saved_errflag = errflag.load(Ordering::Relaxed);
1788            errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1789            crate::ported::parse::parse_init(source);
1790            let program = crate::ported::parse::parse();
1791            let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1792            errflag.store(saved_errflag, Ordering::Relaxed);
1793            if parse_failed || program.lists.is_empty() {
1794                continue;
1795            }
1796            let chunk = crate::compile_zsh::ZshCompiler::new().compile(&program);
1797            self.functions_compiled.insert(name.clone(), chunk);
1798            self.function_source
1799                .insert(name.clone(), source.to_string());
1800        }
1801    }
1802}
1803// END moved-from-exec-rs