//! Workspace trust.
//!
//! Helix can load workspace-local configuration (`.helix/`) and launch language servers, both of
//! which can execute arbitrary code. By default these are gated behind explicit user trust granted
//! per-workspace.
//!
//! Trust is granted with `:workspace-trust` (or the popup) and revoked with `:workspace-untrust`.
//! A grant snapshots a hash of every file under `.helix/`. If those files change later the
//! workspace becomes [`TrustStatus::Stale`] and local config is no longer loaded until the user
//! re-runs `:workspace-trust`. Language servers continue to launch under stale trust because the
//! binaries are configured globally and were not part of the changed surface.
//!
//! ## Storage
//!
//! Each trust entry is a small file at `data_dir()/workspace_trust/<sha256(workspace_path)>`. The
//! filename is the SHA-256 of the workspace's absolute path; the contents are a small
//! `key = value` block:
//!
//! ```text
//! path = /home/user/proj1
//! hash = sha256:abc123...
//! excluded = false
//! ```
//!
//! `hash` is omitted for excluded entries. The "one file per workspace" shape is safe under
//! multiple concurrent helix instances writing trust for *different* workspaces (different
//! filenames). Two instances racing to trust the same workspace converge to identical content.
//!
//! ## Trusted globs (discouraged)
//!
//! `[editor.workspace-trust] trusted = [...]` is an escape hatch for users who keep many repos in a
//! predictable layout (`~/src/github.com/me/*`) and don't want to grant trust one workspace at a
//! time. A workspace whose path matches one of these globs is implicitly trusted for everything.
//!
//! This is deliberately weaker than an explicit `:workspace-trust` grant and is discouraged: it
//! bypasses the `.helix/` hash pin (changes to local config are never re-checked) and it trusts any
//! repository that happens to land under a matching directory, including ones cloned there later. An
//! explicit exclude still wins over a matching glob.

use std::{
    collections::HashMap,
    fmt::Write,
    fs,
    path::{Path, PathBuf},
    sync::Arc,
};

use globset::{Glob, GlobSet, GlobSetBuilder};
use parking_lot::Mutex;
use sha2::{Digest, Sha256};

use crate::{data_dir, find_workspace, find_workspace_in};

/// Checks whether a specific capability is trusted
#[derive(Debug, Clone, Copy)]
pub enum TrustQuery {
    /// Query language server permissions
    Lsp,
    /// Query debug adapter permissions
    Dap,
    /// Query whether `.helix/` config can be loaded
    LocalConfig,
    /// Query whether git integration can trust the .git/config
    Git,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustStatus {
    /// Workspace is trusted for the queried capability.
    Trusted,
    /// No trust decision has been made (and no implicit trust applies).
    Untrusted,
    /// Workspace was previously trusted, but the `.helix/` tree has changed since the grant — the
    /// user should re-trust before local config is re-loaded. LSP launches may still proceed under
    /// stale trust because they use the (unchanged) globally-configured binaries.
    Stale,
    /// Workspace is on the exclude list. Never prompts again.
    Excluded,
}

impl TrustStatus {
    pub fn is_trusted(&self) -> bool {
        matches!(self, Self::Trusted)
    }

    pub fn is_stale(&self) -> bool {
        matches!(self, Self::Stale)
    }

    pub fn is_excluded(&self) -> bool {
        matches!(self, Self::Excluded)
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ImplicitTrustLevel {
    /// Prompt for every workspace.
    None,
    /// Helix-launched server processes (LSP and DAP) start implicitly. Workspace-local config and
    /// git `Trust::Full` still require explicit trust.
    ///
    /// Default: language servers are how most people use the editor and the binaries are global
    /// (PATH-resolved, user-installed), so auto-launching them in a fresh workspace matches
    /// expectations.
    #[default]
    Servers,
    /// Everything is trusted unless the workspace is explicitly excluded.
    Insecure,
}

#[derive(Debug, Clone)]
pub struct Config {
    pub level: ImplicitTrustLevel,
    /// Whether to surface the trust modal on `DocumentDidOpen` for untrusted workspaces.
    pub prompt: bool,
    /// Workspaces whose path matches one of these globs are implicitly trusted.
    pub trusted_globs: GlobSet,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            level: ImplicitTrustLevel::default(),
            prompt: true,
            trusted_globs: GlobSet::empty(),
        }
    }
}

/// Compile workspace-trust glob patterns into a matcher. `~` and environment variables are expanded
/// in each pattern; invalid patterns are logged and skipped rather than failing the whole config
/// load. Returns an empty set (matches nothing) when `patterns` is empty or all entries are invalid.
pub fn build_trusted_globs(patterns: &[String]) -> GlobSet {
    let mut builder = GlobSetBuilder::new();
    for pattern in patterns {
        let expanded = helix_stdx::path::expand(pattern);
        match Glob::new(&expanded.to_string_lossy()) {
            Ok(glob) => {
                builder.add(glob);
            }
            Err(err) => log::error!("ignoring invalid workspace-trust glob {pattern:?}: {err}"),
        }
    }
    builder.build().unwrap_or_else(|err| {
        log::error!("failed to compile workspace-trust globs: {err}");
        GlobSet::empty()
    })
}

/// Runtime workspace-trust state. Cheap to clone (shared `Arc`).
#[derive(Clone)]
pub struct WorkspaceTrust {
    inner: Arc<Mutex<HashMap<PathBuf, CacheEntry>>>,
    config: Config,
}

#[derive(Clone, Copy)]
struct CacheEntry {
    status: TrustStatus,
    /// Whether `.helix/config.toml` or `.helix/languages.toml` exists in the workspace at the time
    /// of the first uncached query. Snapshotted to keep [`WorkspaceTrust::workspace_restricted`]
    /// off the syscall path on repeat calls (statusline indicator runs per render).
    has_local_config: bool,
}

impl WorkspaceTrust {
    pub fn new(config: Config) -> Self {
        Self {
            inner: Arc::new(Mutex::new(HashMap::new())),
            config,
        }
    }

    /// A trust state that grants every capability. Use for non-interactive contexts (CLI grammar
    /// build, `hx --health`) where prompting isn't meaningful.
    pub fn fully_trusted() -> Self {
        Self::new(Config {
            level: ImplicitTrustLevel::Insecure,
            ..Config::default()
        })
    }

    pub fn implicit_level(&self) -> ImplicitTrustLevel {
        self.config.level
    }

    /// Whether the trust modal should be surfaced on `DocumentDidOpen`. When false the user is
    /// only informed via the statusline indicator and acts explicitly via `:workspace-trust`.
    pub fn prompts_enabled(&self) -> bool {
        self.config.prompt
    }

    /// Replace the configuration in-place. Clears the trust cache so the next query re-reads from
    /// disk. This catches external mutations to `.helix/` that happened while helix was running
    /// (the editor's in-memory cache would otherwise keep returning a stale `Trusted` even though
    /// `compute_workspace_hash` would now produce a different digest). Used by `:config-reload`.
    ///
    /// Session-only decisions made via [`Self::deny_once`] are discarded as part of the cache
    /// clear; the trust popup's `prompted` set (scoped to the hook closure) is what suppresses
    /// re-prompting across the reload, not this cache.
    pub fn set_config(&mut self, config: Config) {
        self.config = config;
        self.inner.lock().clear();
    }

    /// Raw on-disk trust status for `workspace`, ignoring implicit-trust-level shortcuts and the
    /// `demote_for_query` mapping. Use this when you need to distinguish *Stale* (was trusted,
    /// `.helix/` changed) from *Untrusted* (never trusted)
    pub fn status(&self, workspace: &Path) -> TrustStatus {
        self.entry(workspace).status
    }

    /// Cache entry for `workspace`, loaded from disk on first call.
    fn entry(&self, workspace: &Path) -> CacheEntry {
        if let Some(entry) = self.inner.lock().get(workspace).copied() {
            return entry;
        }
        let status = load_status(workspace);
        let has_local_config = has_local_config(workspace);
        let entry = CacheEntry {
            status,
            has_local_config,
        };
        self.inner.lock().insert(workspace.to_path_buf(), entry);
        entry
    }

    /// Query trust for `workspace` from the perspective of a specific subsystem.
    pub fn query(&self, workspace: &Path, query: TrustQuery) -> TrustStatus {
        let raw = self.status(workspace);

        // Explicit excludes always win.
        if raw == TrustStatus::Excluded {
            return TrustStatus::Excluded;
        }

        // Implicit-trust shortcuts only apply once we've ruled out excludes.
        if self.config.level == ImplicitTrustLevel::Insecure {
            return TrustStatus::Trusted;
        }
        // A config-listed glob grants full implicit trust to the matching workspace.
        if self.is_glob_trusted(workspace) {
            return TrustStatus::Trusted;
        }
        if self.config.level == ImplicitTrustLevel::Servers
            && matches!(query, TrustQuery::Lsp | TrustQuery::Dap)
        {
            return TrustStatus::Trusted;
        }

        demote_for_query(raw, query)
    }

    /// Query trust for the workspace containing `file`.
    pub fn query_for_file(&self, file: &Path, query: TrustQuery) -> TrustStatus {
        let workspace = file
            .parent()
            .map(|dir| find_workspace_in(dir).0)
            .unwrap_or_else(|| find_workspace().0);
        self.query(&workspace, query)
    }

    /// Query trust for the current working directory's workspace.
    pub fn query_current(&self, query: TrustQuery) -> TrustStatus {
        let workspace = find_workspace().0;
        self.query(&workspace, query)
    }

    /// Whether `workspace` matches one of the configured trust globs. Empty glob set never matches.
    fn is_glob_trusted(&self, workspace: &Path) -> bool {
        !self.config.trusted_globs.is_empty() && self.config.trusted_globs.is_match(workspace)
    }

    /// Workspace-wide "is this workspace in restricted mode and would running `trust`
    /// change anything visible at the workspace level?" check.
    ///
    /// Reads the entire cache entry through `Self::entry` in a single lock acquisition (so the
    /// `Untrusted` branch's `has_local_config` snapshot is consistent with the status read). Cheap
    /// on the hot render path after the first query.
    pub fn workspace_restricted(&self, workspace: &Path) -> bool {
        // If the workspace is fully trusted there's no restrictions.
        if self.config.level == ImplicitTrustLevel::Insecure || self.is_glob_trusted(workspace) {
            return false;
        }
        let entry = self.entry(workspace);
        match entry.status {
            TrustStatus::Stale => true,
            TrustStatus::Trusted | TrustStatus::Excluded => false,
            TrustStatus::Untrusted => entry.has_local_config,
        }
    }

    /// Per-document version of [`Self::workspace_restricted`].
    pub fn restricted_for_doc(&self, workspace: &Path, servers_to_load: bool) -> bool {
        if self.workspace_restricted(workspace) {
            return true;
        }
        if !servers_to_load {
            return false;
        }
        if self.status(workspace) != TrustStatus::Untrusted {
            return false;
        }
        !self.query(workspace, TrustQuery::Lsp).is_trusted()
            || !self.query(workspace, TrustQuery::Dap).is_trusted()
    }

    /// Mark `workspace` trusted. Snapshots the current `.helix/` hash.
    pub fn trust(&self, workspace: &Path) {
        let hash = compute_workspace_hash(workspace);
        let has_local_config = has_local_config(workspace);
        write_entry(
            workspace,
            &DiskEntry {
                hash,
                excluded: false,
            },
        );
        self.inner.lock().insert(
            workspace.to_path_buf(),
            CacheEntry {
                status: TrustStatus::Trusted,
                has_local_config,
            },
        );
    }

    /// Revoke any persisted trust grant or exclusion for `workspace`.
    pub fn untrust(&self, workspace: &Path) {
        remove_entry(workspace);
        self.inner.lock().remove(workspace);
    }

    /// Mark `workspace` excluded — never prompts again.
    pub fn exclude(&self, workspace: &Path) {
        let has_local_config = has_local_config(workspace);
        write_entry(
            workspace,
            &DiskEntry {
                hash: None,
                excluded: true,
            },
        );
        self.inner.lock().insert(
            workspace.to_path_buf(),
            CacheEntry {
                status: TrustStatus::Excluded,
                has_local_config,
            },
        );
    }

    /// Cache an untrusted decision for the current session.
    pub fn deny_once(&self, workspace: &Path) {
        let has_local_config = has_local_config(workspace);
        self.inner.lock().insert(
            workspace.to_path_buf(),
            CacheEntry {
                status: TrustStatus::Untrusted,
                has_local_config,
            },
        );
    }
}

fn has_local_config(workspace: &Path) -> bool {
    workspace.join(".helix").join("config.toml").exists()
        || workspace.join(".helix").join("languages.toml").exists()
}

fn demote_for_query(status: TrustStatus, query: TrustQuery) -> TrustStatus {
    // Stale workspaces have their `.helix/` config changed since trust was granted. LSP launches
    // still rely on globally-configured binaries that weren't part of the changed surface, so they
    // remain Trusted; other queries demote to Untrusted until the user re-trusts.
    match (status, query) {
        (TrustStatus::Stale, TrustQuery::Lsp) => TrustStatus::Trusted,
        (TrustStatus::Stale, _) => TrustStatus::Untrusted,
        _ => status,
    }
}

fn load_status(workspace: &Path) -> TrustStatus {
    match read_entry(workspace) {
        Some(entry) if entry.excluded => TrustStatus::Excluded,
        Some(entry) => {
            let current = compute_workspace_hash(workspace);
            if entry.hash == current {
                TrustStatus::Trusted
            } else {
                TrustStatus::Stale
            }
        }
        None => TrustStatus::Untrusted,
    }
}

// ---------- on-disk format ----------
//
// `data_dir()/workspace_trust/<sha256(canonical_workspace_path)>` is a small key-value file. The
// filename derives from the path, so concurrent helix instances writing trust for *different*
// workspaces never touch the same file. The original path is stored inside as a sanity check and
// for `cat workspace_trust/*` debugging.

struct DiskEntry {
    hash: Option<String>,
    excluded: bool,
}

fn workspace_trust_dir() -> PathBuf {
    data_dir().join("workspace_trust")
}

fn entry_path(workspace: &Path) -> PathBuf {
    workspace_trust_dir().join(path_filename(workspace))
}

fn path_filename(workspace: &Path) -> String {
    let mut hasher = Sha256::new();
    // `Path` is OsStr; encode lossy-but-deterministically. Two workspaces whose normalized paths
    // differ only in non-UTF-8 bytes will collide here, but find_workspace() returns paths derived
    // from CWD walking and that's already lossy on the FS side — accepting parity.
    hasher.update(workspace.as_os_str().to_string_lossy().as_bytes());
    let digest = hasher.finalize();
    hex_encode(&digest)
}

fn hex_encode(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        let _ = write!(out, "{byte:02x}");
    }
    out
}

fn read_entry(workspace: &Path) -> Option<DiskEntry> {
    let path = entry_path(workspace);
    let contents = match fs::read_to_string(&path) {
        Ok(s) => s,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
        Err(err) => {
            log::error!("workspace trust file {path:?} unreadable: {err:?}");
            return None;
        }
    };

    let mut stored_path: Option<String> = None;
    let mut hash: Option<String> = None;
    let mut excluded = false;
    for line in contents.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let Some((k, v)) = line.split_once('=') else {
            continue;
        };
        let k = k.trim();
        let v = v.trim();
        match k {
            "path" => stored_path = Some(v.to_string()),
            "hash" => hash = Some(v.to_string()),
            "excluded" => excluded = v == "true",
            _ => {}
        }
    }

    // Sanity check that we didn't hit a path collision: the path inside the file should match the
    // workspace we looked up.
    if let Some(stored) = stored_path {
        if Path::new(&stored) != workspace {
            log::error!(
                "workspace trust file {path:?} contains path {stored:?}, expected {workspace:?}"
            );
            return None;
        }
    }

    Some(DiskEntry { hash, excluded })
}

fn write_entry(workspace: &Path, entry: &DiskEntry) {
    let dir = workspace_trust_dir();
    if let Err(err) = fs::create_dir_all(&dir) {
        log::error!("Couldn't create workspace trust dir {dir:?}: {err:?}");
        return;
    }
    let path = entry_path(workspace);

    let mut contents = String::new();
    let _ = writeln!(contents, "path = {}", workspace.display());
    if let Some(hash) = &entry.hash {
        let _ = writeln!(contents, "hash = {hash}");
    }
    let _ = writeln!(contents, "excluded = {}", entry.excluded);

    if let Err(err) = fs::write(&path, contents) {
        log::error!("Error writing workspace trust file {path:?}: {err:?}");
    }
}

fn remove_entry(workspace: &Path) {
    let path = entry_path(workspace);
    match fs::remove_file(&path) {
        Ok(()) => {}
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
        Err(err) => log::error!("Error removing workspace trust file {path:?}: {err:?}"),
    }
}

// ---------- hashing ----------

/// SHA-256 of all files under `.helix/`, used to detect changes to local config after trust was
/// granted. Returns `None` if `.helix/` is absent or has no files, so a workspace with no local
/// config can still be trusted.
pub fn compute_workspace_hash(workspace: &Path) -> Option<String> {
    let helix_dir = workspace.join(".helix");
    if !helix_dir.is_dir() {
        return None;
    }

    let mut files: Vec<PathBuf> = Vec::new();
    walk(&helix_dir, &mut files);
    if files.is_empty() {
        return None;
    }
    files.sort();

    let mut hasher = Sha256::new();
    for file in &files {
        let rel = file.strip_prefix(&helix_dir).unwrap_or(file);
        hash_field(&mut hasher, rel.to_string_lossy().as_bytes());
        match fs::read(file) {
            Ok(bytes) => hash_field(&mut hasher, &bytes),
            Err(err) => {
                log::warn!("workspace hash: treating unreadable file {file:?} as empty: {err:?}");
                hash_field(&mut hasher, &[]);
            }
        }
    }
    let digest = hasher.finalize();
    Some(format!("sha256:{}", hex_encode(&digest)))
}

/// Length-prefix a field before feeding it to the hasher.
fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
    hasher.update((bytes.len() as u64).to_le_bytes());
    hasher.update(bytes);
}

fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let Ok(direct) = entry.file_type() else {
            continue;
        };
        if direct.is_dir() {
            // Real directories only — don't follow symlinked directories to avoid filesystem
            // cycles via path-loop symlinks.
            walk(&path, out);
        } else if direct.is_file() {
            out.push(path);
        } else if direct.is_symlink() {
            // Symlinks to files (extremely common: dotfiles managers symlink `.helix/config.toml`
            // to an external location). Follow once via `fs::metadata` which traverses links, then
            // include the file in the hash so mutations to the *target* are still detected.
            if let Ok(target) = fs::metadata(&path) {
                if target.is_file() {
                    out.push(path);
                }
            }
        }
    }
}

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

    fn write_file(path: &Path, contents: &str) {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        let mut f = fs::File::create(path).unwrap();
        f.write_all(contents.as_bytes()).unwrap();
    }

    #[test]
    fn hash_changes_when_helix_dir_changes() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = dir.path();

        let empty = compute_workspace_hash(workspace);
        assert_eq!(empty, None, ".helix/ absent should hash to None");

        write_file(&workspace.join(".helix").join("config.toml"), "a = 1");
        let h1 = compute_workspace_hash(workspace).expect("has files");
        assert!(h1.starts_with("sha256:"));

        // Same content → same hash
        let h1b = compute_workspace_hash(workspace).expect("has files");
        assert_eq!(h1, h1b);

        // Different content → different hash
        write_file(&workspace.join(".helix").join("config.toml"), "a = 2");
        let h2 = compute_workspace_hash(workspace).expect("has files");
        assert_ne!(h1, h2);

        // Added file → different hash
        write_file(&workspace.join(".helix").join("languages.toml"), "");
        let h3 = compute_workspace_hash(workspace).expect("has files");
        assert_ne!(h2, h3);
    }

    #[test]
    fn sha256_known_vector() {
        // Cross-check sha2 crate against a well-known SHA-256 of "abc".
        let mut h = Sha256::new();
        h.update(b"abc");
        assert_eq!(
            hex_encode(&h.finalize()),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn workspace_restricted_hides_when_no_local_config() {
        let dir = tempfile::tempdir().unwrap();
        let workspace = dir.path();

        let trust = WorkspaceTrust::new(Config::default());
        // Untrusted + no .helix/ → indicator should hide.
        assert!(!trust.workspace_restricted(workspace));

        // Untrusted + .helix/config.toml exists → indicator should show.
        write_file(&workspace.join(".helix").join("config.toml"), "a = 1");
        // Bust the cache so the new file is picked up.
        trust.untrust(workspace);
        assert!(trust.workspace_restricted(workspace));

        // After explicit trust, indicator hides.
        trust.trust(workspace);
        assert!(!trust.workspace_restricted(workspace));

        // After mutation (and revoking the cached entry to simulate fresh load), workspace becomes
        // Stale → indicator should show again.
        write_file(&workspace.join(".helix").join("config.toml"), "a = 2");
        trust.inner.lock().remove(workspace);
        assert!(trust.workspace_restricted(workspace));
    }

    #[test]
    fn set_config_invalidates_cache_so_stale_is_detected() {
        // Regression: prior to this fix the editor's WorkspaceTrust cache held a `Trusted` entry
        // after `:workspace-trust` was run. If the user (or another process) modified `.helix/`
        // while helix was running, subsequent queries kept returning Trusted even though
        // `Config::load_default`'s transient WorkspaceTrust would correctly see Stale. Reloading
        // config now clears the cache so the editor sees the new state on the next query.
        let dir = tempfile::tempdir().unwrap();
        let workspace = dir.path();
        write_file(&workspace.join(".helix").join("config.toml"), "a = 1");

        let mut trust = WorkspaceTrust::new(Config::default());
        trust.trust(workspace);
        assert_eq!(trust.status(workspace), TrustStatus::Trusted);

        // External mutation while helix is running.
        write_file(&workspace.join(".helix").join("config.toml"), "a = 2");

        // Without a refresh, the cache still says Trusted.
        assert_eq!(trust.status(workspace), TrustStatus::Trusted);

        // Simulate `:config-reload`.
        trust.set_config(Config::default());

        // Fresh disk read now detects the hash mismatch.
        assert_eq!(trust.status(workspace), TrustStatus::Stale);
    }

    #[cfg(unix)]
    #[test]
    fn hash_includes_symlinked_config_file() {
        // Regression: `.helix/config.toml` is commonly a symlink to an external location (dotfiles
        // managers). The old `walk` only matched `is_dir() | is_file()`, so symlinks fell through
        // and were excluded from the hash entirely — trust was granted with an effectively empty
        // hash and config mutations went undetected.
        use std::os::unix::fs::symlink;

        let dir = tempfile::tempdir().unwrap();
        let workspace = dir.path();
        let external = dir.path().join("external_config.toml");
        write_file(&external, "a = 1");

        fs::create_dir_all(workspace.join(".helix")).unwrap();
        symlink(&external, workspace.join(".helix").join("config.toml")).unwrap();

        let h1 = compute_workspace_hash(workspace).expect("symlink should be hashed");

        // Mutating the symlink *target* must change the hash.
        write_file(&external, "a = 2");
        let h2 = compute_workspace_hash(workspace).expect("symlink should be hashed");
        assert_ne!(
            h1, h2,
            "symlinked config file mutations must affect the hash"
        );
    }

    #[test]
    fn hash_distinguishes_file_split() {
        // Regression: without length-prefixing, two files
        //   foo.toml: "a"
        //   bar.toml: "b"
        // would feed the same byte stream to the hasher as a single file
        //   foo.toml: "a\0bar.toml\0b"
        // because the \0 separators were indistinguishable from content.
        let dir1 = tempfile::tempdir().unwrap();
        let split = dir1.path();
        write_file(&split.join(".helix").join("foo.toml"), "a");
        write_file(&split.join(".helix").join("bar.toml"), "b");

        let dir2 = tempfile::tempdir().unwrap();
        let merged = dir2.path();
        write_file(&merged.join(".helix").join("foo.toml"), "a\0bar.toml\0b");

        let h1 = compute_workspace_hash(split).expect("split has files");
        let h2 = compute_workspace_hash(merged).expect("merged has files");
        assert_ne!(
            h1, h2,
            "length-prefixing must prevent file-vs-content ambiguity"
        );
    }

    #[test]
    fn level_insecure_does_not_bypass_excluded() {
        // Regression: under `level = "insecure"` an exclude entry on disk must still produce
        // `TrustStatus::Excluded` from `query()`. The old shortcut read only the in-memory cache,
        // so a cold cache silently returned `Trusted`.
        let dir = tempfile::tempdir().unwrap();
        let workspace = dir.path();

        // Seed an excluded entry via a level=None trust state.
        let bootstrap = WorkspaceTrust::new(Config::default());
        bootstrap.exclude(workspace);

        // Fresh state (cold cache) under level=Insecure.
        let trust = WorkspaceTrust::new(Config {
            level: ImplicitTrustLevel::Insecure,
            ..Config::default()
        });
        assert_eq!(
            trust.query(workspace, TrustQuery::Lsp),
            TrustStatus::Excluded,
            "level=insecure must honor disk-persisted excludes even on a cold cache"
        );
        assert_eq!(
            trust.query(workspace, TrustQuery::LocalConfig),
            TrustStatus::Excluded
        );
    }

    #[test]
    fn trusted_glob_grants_full_trust_but_exclude_wins() {
        let dir = tempfile::tempdir().unwrap();
        let trusted = dir.path().join("trusted_proj");
        fs::create_dir_all(&trusted).unwrap();

        let pattern = format!("{}/*", dir.path().display());
        let trust = WorkspaceTrust::new(Config {
            level: ImplicitTrustLevel::None,
            trusted_globs: build_trusted_globs(&[pattern]),
            ..Config::default()
        });

        // Matching workspace is fully trusted for every capability, even local config (which
        // `level = "servers"` would still gate).
        assert_eq!(
            trust.query(&trusted, TrustQuery::LocalConfig),
            TrustStatus::Trusted
        );
        assert_eq!(trust.query(&trusted, TrustQuery::Git), TrustStatus::Trusted);
        assert!(!trust.workspace_restricted(&trusted));

        // A non-matching path (no `dir/*` segment match: it has a deeper component) still trusts
        // since `other` is directly under `dir`. Use a sibling outside `dir` to confirm no match.
        let outside = tempfile::tempdir().unwrap();
        assert_eq!(
            trust.query(outside.path(), TrustQuery::LocalConfig),
            TrustStatus::Untrusted
        );

        // Explicit excludes beat a matching glob.
        trust.exclude(&trusted);
        assert_eq!(
            trust.query(&trusted, TrustQuery::LocalConfig),
            TrustStatus::Excluded
        );
        assert_eq!(
            trust.query(&trusted, TrustQuery::Lsp),
            TrustStatus::Excluded
        );
    }

    #[test]
    fn workspace_restricted_detects_stale() {
        // Regression: a previously-trusted workspace whose .helix/ has since been modified must be
        // reported as restricted (so the indicator and stale-hint can fire). The old code piped
        // Stale through `demote_for_query` and matched Untrusted, which made the `Stale` arm of
        // `workspace_restricted` unreachable.
        let dir = tempfile::tempdir().unwrap();
        let workspace = dir.path();
        write_file(&workspace.join(".helix").join("config.toml"), "a = 1");

        let trust = WorkspaceTrust::new(Config::default());
        trust.trust(workspace);
        assert!(!trust.workspace_restricted(workspace));
        assert_eq!(trust.status(workspace), TrustStatus::Trusted);

        // Mutate `.helix/` and bust the in-memory cache to force a re-read.
        write_file(&workspace.join(".helix").join("config.toml"), "a = 2");
        trust.inner.lock().remove(workspace);

        assert_eq!(trust.status(workspace), TrustStatus::Stale);
        assert!(
            trust.workspace_restricted(workspace),
            "Stale workspace must light up the restricted indicator"
        );
    }

    #[test]
    fn path_filename_is_stable_and_path_specific() {
        let a = path_filename(Path::new("/home/u/proj1"));
        let b = path_filename(Path::new("/home/u/proj1"));
        let c = path_filename(Path::new("/home/u/proj2"));
        assert_eq!(a, b);
        assert_ne!(a, c);
        assert_eq!(a.len(), 64); // hex sha256
    }
}
