#![allow(clippy::doc_lazy_continuation)] // Some doc comments use continuation formatting clippy flags
//! # cqs - Code Intelligence and RAG for AI Agents
//!
//! Semantic search, call graph analysis, impact tracing, type dependencies, and smart
//! context assembly — all in single tool calls. Local ML embeddings, GPU-accelerated.
//!
//! ## Features
//!
//! - **Semantic search**: Hybrid RRF (keyword + vector) with configurable embedding models (embeddinggemma-300m default; bge-large, bge-large-ft, E5-base, v9-200k, nomic-coderank, qwen3-embedding-4b, qwen3-embedding-8b, and custom ONNX presets). High recall on the curated fixture eval; see README.md#retrieval-quality for current numbers.
//! - **Call graphs**: Callers, callees, transitive impact, shortest-path tracing between functions
//! - **Impact analysis**: What breaks if you change X? Callers + affected tests + risk scoring
//! - **Type dependencies**: Who uses this type? What types does this function use?
//! - **Smart context assembly**: `gather` (search + BFS expansion), `task` (scout + gather + impact + placement), `scout` (pre-investigation dashboard)
//! - **Diff review & CI**: Structured risk analysis, dead code detection in diffs, gating pipeline
//! - **Batch & chat modes**: Persistent session with pipeline syntax (`search "error" | callers | test-map`)
//! - **MCP server**: stdio↔daemon bridge exposing 30 read-only tools (plus 4 gated mutation tools) to any MCP-capable client; GPU-free, requires a running daemon
//! - **Notes with sentiment**: Unified memory system for AI collaborators
//! - **Multi-language**: 54 languages + L5X/L5K PLC exports, with multi-grammar injection (HTML→JS/CSS, Svelte, Vue, Razor, etc.)
//! - **Type-aware embeddings**: Full signatures appended to NL descriptions for richer type discrimination
//! - **Doc comment generation**: `--improve-docs` stages proposed doc comments as `.cqs/proposed-docs/*.patch` files for review (`--apply` writes them directly to source)
//! - **HyDE query predictions**: `--hyde-queries` generates synthetic search queries per function for improved recall
//! - **Training data generation**: `train-data` command generates fine-tuning triplets from git history
//! - **GPU acceleration**: CUDA/TensorRT with CPU fallback (CoreML and ROCm scaffolding present, wiring not yet complete)
//! - **Document conversion**: PDF, HTML, CHM, Web Help → cleaned Markdown (optional `convert` feature)
//!
//! ## Quick Start
//!
//! ```no_run
//! use cqs::{Embedder, Parser, Store};
//! use cqs::embedder::ModelConfig;
//! use cqs::store::SearchFilter;
//!
//! # fn main() -> anyhow::Result<()> {
//! // Initialize components. `resolve_index_db` honours the slot layout
//! // (post-#1105: `.cqs/slots/<active>/index.db`) and falls back to the
//! // pre-migration `.cqs/index.db` path on unmigrated projects.
//! let parser = Parser::new()?;
//! let embedder = Embedder::new(ModelConfig::resolve(None, None))?;
//! let cqs_dir = cqs::resolve_index_dir(std::path::Path::new("."));
//! let store = Store::open(&cqs::resolve_index_db(&cqs_dir))?;
//!
//! // Parse and embed a file
//! let chunks = parser.parse_file(std::path::Path::new("src/main.rs"))?;
//! let embeddings = embedder.embed_documents(
//!     &chunks.iter().map(|c| c.content.as_str()).collect::<Vec<_>>()
//! )?;
//!
//! // Search for similar code (hybrid RRF search). #1349: SearchFilter is
//! // `#[non_exhaustive]`, so external callers construct via `Default` and
//! // assign fields — adding a new field is a one-line struct edit upstream.
//! let query_embedding = embedder.embed_query("parse configuration file")?;
//! let filter = {
//!     let mut f = SearchFilter::default();
//!     f.enable_rrf = true;
//!     f.query_text = "parse configuration file".to_string();
//!     f
//! };
//! let results = store.search_filtered(&query_embedding, &filter, 5, 0.3)?;
//! # Ok(())
//! # }
//! ```
//!
// cqs routinely narrows `u64` row counts and SQLite `i64` row IDs
// to `usize` (e.g. `chunk_count as usize` in `cli/store.rs`, HNSW ID map
// loads, store batch readers). On a 32-bit target those casts would silently
// truncate once a corpus exceeds ~4 billion elements. Rather than sprinkle
// per-site checked casts everywhere, gate the whole crate at compile time —
// every target cqs ships for is 64-bit anyway.
#[cfg(not(target_pointer_width = "64"))]
compile_error!("cqs requires a 64-bit target (target_pointer_width = \"64\")");

// Public library API modules
pub mod audit;
pub mod aux_model;
pub mod cache;
pub mod config;
pub mod convert;
pub mod embedder;
pub mod fs;
pub mod hnsw;
pub mod index;
pub mod kind;
pub mod language;
pub mod note;
pub mod output_format;
pub mod parser;
pub mod reference;
pub mod splade;
pub mod store;
pub mod train_data;
pub mod vendored;
pub mod worktree;
pub mod worktree_overlay;

pub mod ci;
pub mod eval;
pub mod health;
pub mod reranker;
#[cfg(feature = "serve")]
pub mod serve;
pub mod slot;
pub mod suggest;

// Internal modules - not part of public library API
// These are pub(crate) to hide implementation details, but specific items are
// re-exported below for use by the binary crate (CLI) and integration tests.
pub(crate) mod diff;
pub(crate) mod diff_parse;
pub use diff_parse::{parse_unified_diff, DiffHunk};
pub mod drift;
pub use drift::{detect_drift, DriftEntry, DriftResult};
pub(crate) mod focused_read;
pub(crate) mod gather;
pub(crate) mod impact;
pub mod limits;
pub(crate) mod math;
pub(crate) mod nl;
pub(crate) mod onboard;
pub(crate) mod ort_helpers;
pub(crate) mod project;
pub(crate) mod related;
pub(crate) mod review;
pub use review::{
    review_diff, review_diff_overlay, ReviewNoteEntry, ReviewResult, ReviewedFunction, RiskSummary,
};
#[cfg(feature = "llm-summaries")]
pub mod doc_writer;
#[cfg(feature = "llm-summaries")]
pub mod llm;
pub mod plan;
pub(crate) mod scout;
pub mod search;
// Shared `#[serde(skip_serializing_if = ...)]` predicates so envelope
// modules don't redeclare 1-line `is_false` / `is_zero_usize` / `is_true`
// helpers per file. See serde_helpers.rs. `pub` (not `pub(crate)`) so the
// binary crate's `cli/` modules can reference these via path strings inside
// `serde(skip_serializing_if = "cqs::serde_helpers::is_false")` attributes.
pub mod serde_helpers;
pub(crate) mod structural;
pub(crate) mod task;
pub(crate) mod where_to_add;

// Pure arg-shaping translator for daemon-forward mode. Lives in the
// library (not `src/cli/`) so integration tests can exercise it without
// reaching into the binary-only `cli` module tree.
pub mod daemon_translate;

// Freshness snapshot exposed via the daemon socket so `cqs status
// --watch-fresh` can answer "is the index fresh?". Watch loop publishes
// every cycle; daemon socket handler reads. Lives in lib so the CLI's
// `cqs status` command and the `daemon_status` socket helper share the
// wire shape.
pub mod watch_status;

#[cfg(test)]
pub mod test_helpers;

/// Crate-wide test mutex for serializing access to the `CQS_ONNX_DIR`
/// process-global env var. Serializes the three cohorts that mutate it:
///
/// - `embedder::tests::ensure_model_tests` (3 tests)
/// - `embedder::tests::embedder_init_failure` (2 tests)
/// - `cli::commands::infra::doctor::tests` (4 tests)
///
/// A single shared lock (rather than a per-cohort `static Mutex<()>`) so all
/// cohorts serialize through one Mutex — separate locks don't serialize
/// against each other, and under cargo's parallel runner a poisoned lock from
/// a 401-on-CI HF download cascades into `PoisonError` panics in sibling
/// tests. Same shape as `crate::llm::LLM_ENV_LOCK`.
///
/// Visibility is `pub` (not `pub(crate)`) because `doctor::tests` lives
/// in the binary crate and reaches the lib via `cqs::ONNX_DIR_ENV_LOCK`;
/// `pub(crate)` doesn't cross the lib→bin boundary even within one
/// Cargo package, and `#[cfg(test)]` doesn't either (the lib used by
/// `cqs` bin tests is built with `cfg(test)` for the lib unit-test
/// target only, not when serving as a dep). Public-but-cheap (an 8-byte
/// `Mutex<()>` with `#[doc(hidden)]`) is the right escape hatch.
#[doc(hidden)]
pub static ONNX_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[cfg(feature = "cuda-index")]
pub mod cagra;

// Tiered ANN backend (cuVS tiered index). Behind its own feature + a fork pin
// because `cuvs::tiered_index` exists only on the pinned fork branch; the
// module itself enforces `#![cfg(feature = "tiered-index")]`.
#[cfg(feature = "tiered-index")]
pub mod tiered;

pub use audit::parse_duration;
pub use embedder::{Embedder, Embedding};
pub use hnsw::HnswIndex;
pub use index::{DistanceMetric, IndexResult, VectorIndex};
pub use note::{
    parse_notes, path_matches_mention, rewrite_notes_file, NoteEntry, NoteError, NoteFile,
    MAX_NOTE_MENTIONS, MAX_NOTE_MENTION_BYTES, MAX_NOTE_TEXT_BYTES, NOTES_HEADER,
};
pub use parser::{canonical_hash_fallback, collapse_whitespace, parser_version, Chunk, Parser};
pub use reranker::{NoopReranker, OnnxReranker, Reranker};
pub use store::{HnswKind, ModelInfo, SearchFilter, Store};

// Re-exports for binary crate (CLI) — these are NOT part of the public library
// API but need to be accessible to src/cli/* and tests/.
//
// Each module lists exactly what crosses the lib boundary rather than
// re-exporting with `pub use module::*`. A glob would silently widen the
// surface area — a new `pub struct InternalScratch` in `gather/mod.rs`
// would auto-leak to `cqs::InternalScratch`. New pub items in submodules
// stay internal until explicitly added here.
pub use diff::{semantic_diff, DiffEntry, DiffResult};
pub use focused_read::COMMON_TYPES;
pub use gather::{
    gather, gather_cross_index_with_index, gather_max_nodes, gather_with_graph,
    gather_with_graph_overlay, gather_with_overlay, GatherDirection, GatherOptions, GatherResult,
    GatheredChunk, DEFAULT_MAX_EXPANDED_NODES,
};
/// Cross-project call graph types and context.
pub mod cross_project {
    pub use crate::impact::cross_project::{
        analyze_impact_cross, trace_cross, CrossProjectHop, CrossProjectTraceResult,
    };
    pub use crate::store::calls::cross_project::{
        CrossProjectCallee, CrossProjectCaller, CrossProjectContext, CrossProjectTestChunk,
        NamedStore,
    };
}
pub use impact::{
    analyze_diff_impact, analyze_diff_impact_with_graph, analyze_impact, analyze_impact_overlay,
    compute_hints, compute_hints_batch, compute_hints_with_graph, compute_risk_and_tests,
    compute_risk_batch, diff_impact_empty_json, diff_impact_to_json, find_hotspots,
    find_test_matches, format_test_suggestions, impact_to_json, impact_to_mermaid,
    map_hunks_to_functions, suggest_tests, CallerDetail, ChangedFunction, DiffImpactResult,
    DiffImpactSummary, DiffTestInfo, FunctionHints, ImpactOptions, ImpactResult, RiskLevel,
    RiskScore, TestInfo, TestMatch, TestSuggestion, TransitiveCaller, TypeImpacted,
    DEFAULT_MAX_TEST_SEARCH_DEPTH,
};
pub use nl::{
    generate_nl_description_with_seq_len, generate_nl_with_call_context_and_summary,
    generate_nl_with_template_and_seq_len, normalize_for_fts, tokenize_identifier, CallContext,
    NlTemplate,
};
pub use onboard::{
    onboard, OnboardEntry, OnboardResult, OnboardSummary, TestEntry, TypeInfo,
    DEFAULT_ONBOARD_DEPTH,
};
pub use project::{
    search_across_projects, CrossProjectResult, ProjectEntry, ProjectError, ProjectRegistry,
};
pub use related::{find_related, RelatedFunction, RelatedResult};
pub use scout::{
    scout, scout_with_options, scout_with_overlay, ChunkRole, FileGroup, ScoutChunk, ScoutOptions,
    ScoutResult, ScoutSummary, DEFAULT_SCOUT_SEARCH_LIMIT, DEFAULT_SCOUT_SEARCH_THRESHOLD,
};
pub use search::{parse_target, resolve_target, ResolvedTarget};
pub use structural::Pattern;
pub use task::{
    extract_modify_targets, task, task_with_resources, task_with_resources_overlay, FunctionRisk,
    TaskResult, TaskSummary,
};
pub use where_to_add::{
    suggest_placement, suggest_placement_with_options, FileSuggestion, LanguagePatternDef,
    LocalPatterns, PlacementOptions, PlacementResult, VisibilityRule,
    DEFAULT_PLACEMENT_SEARCH_LIMIT, DEFAULT_PLACEMENT_SEARCH_THRESHOLD,
};

#[cfg(feature = "cuda-index")]
pub use cagra::CagraIndex;

use std::path::PathBuf;

/// Unified error type for analysis operations (scout, where-to-add, etc.)
#[derive(Debug, thiserror::Error)]
pub enum AnalysisError {
    #[error(transparent)]
    Store(#[from] store::StoreError),
    #[error("embedding failed: {0}")]
    Embedder(#[from] embedder::EmbedderError),
    #[error("not found: {0}")]
    NotFound(String),
    #[error("{phase} phase failed: {message}")]
    Phase {
        phase: &'static str,
        message: String,
    },
}

/// Name of the per-project index directory (created by `cqs init`).
pub const INDEX_DIR: &str = ".cqs";

/// Filename of the SQLite index database inside [`INDEX_DIR`].
pub const INDEX_DB_FILENAME: &str = "index.db";

/// Legacy index directory name. Used for auto-migration to `.cqs/`.
const LEGACY_INDEX_DIR: &str = ".cq";

/// Resolve the index directory for a project, migrating from `.cq/` to `.cqs/` if needed.
///
/// If the legacy `.cq/` exists and `.cqs/` does not, renames it automatically.
/// Falls back gracefully if the rename fails (e.g., permissions).
///
/// **Worktree fallback:** when neither `<project_root>/.cqs/` nor
/// the legacy `<project_root>/.cq/` exists AND `project_root` is a git
/// worktree (per [`crate::worktree::resolve_main_project_dir`]), this
/// function returns the main project's `.cqs/` if that one exists, and
/// records the redirect via [`crate::worktree::record_worktree_stale`].
/// JSON envelope writers consult [`crate::worktree::is_worktree_stale`]
/// and add `worktree_stale: true` + `worktree_name` to `_meta` so
/// consuming agents know the served index reflects main's branch, not
/// uncommitted edits in this worktree. When the main project is also
/// uninitialised the function returns `<project_root>/.cqs/` unchanged
/// so the caller's "no index found" error message points at the
/// expected layout.
pub fn resolve_index_dir(project_root: &Path) -> PathBuf {
    let new_dir = project_root.join(INDEX_DIR);
    let old_dir = project_root.join(LEGACY_INDEX_DIR);

    if old_dir.exists() && !new_dir.exists() && std::fs::rename(&old_dir, &new_dir).is_ok() {
        tracing::info!("Migrated index directory from .cq/ to .cqs/");
    }

    if new_dir.exists() {
        return new_dir;
    }
    if old_dir.exists() {
        return old_dir;
    }

    // Worktree fallback. `git worktree add` doesn't copy `.cqs/`; without
    // this fallback a worktree with no index returns the empty path and
    // every cqs command in it errors, pushing agents to absolute paths
    // under main's tree.
    match crate::worktree::lookup_main_cqs_dir(project_root) {
        crate::worktree::MainIndexLookup::WorktreeUseMain {
            worktree_root,
            main_cqs,
            ..
        } => {
            crate::worktree::record_worktree_stale(&worktree_root);
            tracing::info!(
                worktree = %worktree_root.display(),
                main_cqs = %main_cqs.display(),
                "Worktree has no .cqs/; serving from main's index (responses tagged worktree_stale=true)"
            );
            return main_cqs;
        }
        crate::worktree::MainIndexLookup::WorktreeMainEmpty {
            worktree_root,
            main_root,
        } => {
            // Both the worktree and main are uninitialised. The
            // caller will error with "no index found" against the
            // returned (worktree) path; the warn here makes both
            // candidate paths visible in the journal so the operator
            // knows running `cqs index` in main also fixes the
            // worktree.
            tracing::warn!(
                worktree = %worktree_root.display(),
                main = %main_root.display(),
                worktree_cqs = %new_dir.display(),
                main_cqs = %main_root.join(INDEX_DIR).display(),
                "No cqs index found in worktree OR in its main project — \
                 run `cqs index` in either to populate (main is preferred since \
                 worktrees pick up its index automatically)"
            );
        }
        crate::worktree::MainIndexLookup::OwnIndex { .. }
        | crate::worktree::MainIndexLookup::NotWorktree => {
            // OwnIndex is unreachable here (we only entered this
            // branch because new_dir didn't exist, which contradicts
            // OwnIndex). NotWorktree is the regular case — fall
            // through to return new_dir below.
        }
    }

    new_dir
}

/// Compute the slot directory path: `<project_cqs_dir>/slots/<slot_name>/`.
///
/// Convenience wrapper around [`crate::slot::slot_dir`] for callers that
/// already imported `cqs::resolve_index_dir`.
pub fn resolve_slot_dir(project_cqs_dir: &Path, slot_name: &str) -> PathBuf {
    crate::slot::slot_dir(project_cqs_dir, slot_name)
}

/// Resolve the active index.db path within a project's `.cqs/` dir.
///
/// Honors the slot resolution order (`CQS_SLOT` env > `.cqs/active_slot` file >
/// `"default"`) and falls back to the pre-migration `.cqs/index.db` layout for
/// unmigrated projects (cross-project search, external references).
///
/// Returns the slot path even when nothing exists, so the caller's
/// "not found" error message points at the forward-looking layout.
pub fn resolve_index_db(project_cqs_dir: &Path) -> PathBuf {
    if let Ok(resolved) = crate::slot::resolve_slot_name(None, project_cqs_dir) {
        let slot_path =
            crate::slot::slot_dir(project_cqs_dir, &resolved.name).join(INDEX_DB_FILENAME);
        if slot_path.exists() {
            return slot_path;
        }
    }
    let legacy = project_cqs_dir.join(INDEX_DB_FILENAME);
    if legacy.exists() {
        return legacy;
    }
    crate::slot::slot_dir(project_cqs_dir, crate::slot::DEFAULT_SLOT).join(INDEX_DB_FILENAME)
}

/// Default embedding dimension, derived from `ModelConfig::default_model().dim`.
/// The actual dimension is detected at runtime from the model output.
/// Use `Embedder::embedding_dim()` for the runtime value.
pub const EMBEDDING_DIM: usize = embedder::DEFAULT_DIM;

/// Test whether a string is one of the canonical "off" tokens the cqs CLI
/// accepts in `CQS_*` env vars: `"0"`, `"false"`, `"no"`, `"off"` —
/// case-insensitive, whitespace-trimmed.
///
/// No companion `env_truthy` — the truthy spelling list (e.g. should `"y"`
/// work?) deserves its own pass.
#[inline]
pub fn env_falsy(value: &str) -> bool {
    matches!(
        value.trim().to_ascii_lowercase().as_str(),
        "0" | "false" | "no" | "off"
    )
}

/// Convert a [`std::time::Duration`] to milliseconds as `i64` for storage in
/// SQLite `INTEGER` mtime columns.
///
/// `as_millis()` returns `u128`; a bare `as i64` cast would silently wrap a
/// duration past `~292M years` since the Unix epoch to a negative value.
/// `i64::try_from` collapses overflow to `i64::MAX` (the closest representable
/// "far future" mtime); a truncating wrap would instead invert monotonic
/// ordering and break freshness comparisons. Real-world mtimes never approach
/// this cap.
#[inline]
pub fn duration_to_mtime_millis(d: std::time::Duration) -> i64 {
    i64::try_from(d.as_millis()).unwrap_or(i64::MAX)
}

/// Defensive `SystemTime::now() → Unix seconds as i64`.
///
/// Returns `None` when the clock is before epoch (RTC mis-set, hypervisor
/// pause, NTP-pre-sync boot) and emits a `tracing::warn!` once per process
/// so journalctl operators can correlate stale snapshots / timestamps with
/// bad-clock conditions. Use everywhere instead of bare
/// `SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64)`
/// which silently returns `None` on overflow and `0` on epoch errors.
pub fn unix_secs_i64() -> Option<i64> {
    use std::sync::OnceLock;
    static WARNED: OnceLock<()> = OnceLock::new();
    match std::time::SystemTime::now().duration_since(std::time::SystemTime::UNIX_EPOCH) {
        Ok(d) => i64::try_from(d.as_secs()).ok(),
        Err(_) => {
            WARNED.get_or_init(|| {
                tracing::warn!(
                    "system clock is before UNIX_EPOCH — timestamps will be None until NTP sync; check `timedatectl` / `chronyc tracking`",
                );
            });
            None
        }
    }
}

// # Batch Size Constants
//
// `const BATCH_SIZE` definitions across store/pipeline/search modules,
// intentionally local — each is tuned for its SQL query shape.
//
// SQLite host-parameter ceiling: 32766 (SQLite ≥3.32.0).
// Compute the per-statement row cap via
// `crate::store::helpers::sql::max_rows_per_statement(params_per_row)`,
// which returns `min(rows, SQLITE_MAX_VARIABLES / params_per_row)`. New
// batched SQL should call this helper rather than hard-coding sizes.
//
// Non-SQL pinned defaults:
//   EMBED_BATCH_SIZE = 64    — ONNX inference (CQS_EMBED_BATCH_SIZE)
//   FILE_BATCH_SIZE = 5000   — pipeline file processing (CQS_FILE_BATCH_SIZE)
//   HNSW_BATCH_SIZE = 10000  — HNSW insert
//   MAX_BATCH_SIZE = 10000   — Claude Batches API limit
//
// Do not centralize. If adding a batched SQL query, call
// `max_rows_per_statement(N)` rather than picking a number.

/// Unified test-chunk detection heuristic.
///
/// Returns `true` if a chunk looks like a test based on its name or file path.
/// Used by scout, impact, and where_to_add to filter out tests from analysis.
///
/// **Not** used by `store::calls::find_dead_code`, which has its own SQL-based
/// detection (`build_test_chunk_filter` — keyed on the `chunk_type = 'test'` tag,
/// plus `build_test_content_markers`/`build_test_path_patterns` for tagless
/// languages) that also checks content markers like `#[test]` and `@Test`.
pub fn is_test_chunk(name: &str, file: &str) -> bool {
    // Name-based patterns from the language registry.
    //
    // Single source of truth for both this matcher and
    // `store::calls::build_test_chunk_filter`. The default set uses `Test\_%`, which
    // matches `Test_bar` but NOT `TestRegistry` (a looser `Test%` would
    // incorrectly demote test-framework API types). Languages
    // with their own conventions (Kotlin/Swift `should_*`, JUnit5
    // `@DisplayName`, BDD `_when_should_*`) extend the set via
    // `LanguageDef::test_name_patterns`. SQL LIKE syntax with `\_` for
    // literal underscore — `sql_like_matches` already supports the escape.
    for pattern in language::REGISTRY.all_test_name_patterns() {
        if sql_like_matches(name, pattern) {
            return true;
        }
    }
    // Path-based patterns from the language registry (all 54 languages).
    // Patterns use SQL LIKE syntax: `%` = any chars, `\_` = literal underscore.
    //
    // Skip the `\\` → `/` replace when there's no backslash in the input
    // (the common case on Linux); allocate a fresh `String` only on
    // Windows-style paths.
    let normalized: std::borrow::Cow<'_, str> = if file.contains('\\') {
        std::borrow::Cow::Owned(file.replace('\\', "/"))
    } else {
        std::borrow::Cow::Borrowed(file)
    };
    for pattern in language::REGISTRY.all_test_path_patterns() {
        if sql_like_matches(&normalized, pattern) {
            return true;
        }
        // Registry patterns assume a prefix (e.g. `%/tests/%` needs something
        // before `/tests/`). Relative paths like `tests/foo.rs` lack that prefix,
        // so also try with a synthetic `/` prepended.
        if !normalized.starts_with('/') {
            let prefixed = format!("/{normalized}");
            if sql_like_matches(&prefixed, pattern) {
                return true;
            }
        }
    }
    false
}

/// Match a path against a SQL `LIKE` pattern.
///
/// Supports `%` (any sequence of characters) and `\_` (literal underscore).
/// All other characters are matched literally. Used by `is_test_chunk` to
/// evaluate the registry's `test_path_patterns`.
fn sql_like_matches(path: &str, pattern: &str) -> bool {
    // Convert SQL LIKE pattern to segments split on `%`.
    // `\_` is an escaped literal underscore in SQL LIKE — unescape it first.
    let unescaped = pattern.replace("\\_", "_");
    let parts: Vec<&str> = unescaped.split('%').collect();

    // Single segment (no wildcards) — exact match.
    if parts.len() == 1 {
        return path == parts[0];
    }

    let mut pos = 0;

    for (i, part) in parts.iter().enumerate() {
        if part.is_empty() {
            continue;
        }
        if i == 0 {
            // First segment must be a prefix.
            if !path.starts_with(part) {
                return false;
            }
            pos = part.len();
        } else if i == parts.len() - 1 {
            // Last segment must be a suffix, and must not overlap with what we've consumed.
            if !path[pos..].ends_with(part) {
                return false;
            }
        } else {
            // Interior segment — find it after `pos`.
            match path[pos..].find(part) {
                Some(offset) => pos += offset + part.len(),
                None => return false,
            }
        }
    }
    true
}

use std::path::Path;

/// Normalize a path to a string with forward slashes.
///
/// Converts `Path`/`PathBuf` to `String`, replacing backslashes with forward slashes
/// for cross-platform consistency (WSL, Windows paths in JSON output).
///
/// Strips the Windows `\\?\` UNC prefix (and `\\?\UNC\`) before slash
/// conversion so JSON output and chunk IDs don't carry the verbatim path
/// marker. `dunce::canonicalize` strips most of these at ingest, but callers
/// passing already-canonicalized `&Path` get symmetric behavior here.
pub fn normalize_path(path: &Path) -> String {
    let raw = path.to_string_lossy();
    let stripped = strip_windows_verbatim_prefix(&raw);
    stripped.replace('\\', "/")
}

/// Normalize backslashes to forward slashes in a string path.
///
/// For already-stringified paths. Strips Windows `\\?\` / `\\?\UNC\` verbatim
/// prefix before slash conversion. Returns the input unchanged on
/// non-Windows-flavored strings.
pub fn normalize_slashes(path: &str) -> String {
    strip_windows_verbatim_prefix(path).replace('\\', "/")
}

/// Strip the Windows verbatim path prefix before slash normalization.
///
/// Recognized prefixes:
/// - `\\?\C:\foo`  → `C:\foo`
/// - `\\?\UNC\server\share` → `\\server\share`
pub(crate) fn strip_windows_verbatim_prefix(s: &str) -> String {
    if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
        // `\\?\UNC\server\share` → `\\server\share`
        let mut out = String::with_capacity(rest.len() + 2);
        out.push_str(r"\\");
        out.push_str(rest);
        out
    } else if let Some(rest) = s.strip_prefix(r"\\?\") {
        rest.to_string()
    } else {
        s.to_string()
    }
}

/// Generate an unpredictable u64 suffix for temporary file names.
///
/// Uses [`std::collections::hash_map::RandomState`] (seeded by the OS on each
/// process start) to produce a value that is different every run and resists
/// symlink-based TOCTOU attacks on temp-file paths.
pub fn temp_suffix() -> u64 {
    use std::hash::{BuildHasher, Hasher};
    std::collections::hash_map::RandomState::new()
        .build_hasher()
        .finish()
}

/// Extract a human-readable message from a thread panic payload.
///
/// Handles `&str` and `String` payloads (the two common forms produced by
/// `panic!`); falls back to `"unknown panic"` for any other type.
pub fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
    if let Some(s) = payload.downcast_ref::<&str>() {
        (*s).to_string()
    } else if let Some(s) = payload.downcast_ref::<String>() {
        s.clone()
    } else {
        "unknown panic".to_string()
    }
}

/// Serde serializer for `PathBuf` fields: forward-slash normalized.
///
/// Use as `#[serde(serialize_with = "crate::serialize_path_normalized")]`
pub fn serialize_path_normalized<S>(path: &Path, serializer: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    serializer.serialize_str(&normalize_path(path))
}

/// Relativize a path against a root and normalize separators for display.
///
/// Strips `root` prefix if present, converts backslashes to forward slashes.
pub fn rel_display(path: &Path, root: &Path) -> String {
    normalize_path(path.strip_prefix(root).unwrap_or(path))
}

/// Relativize `file` against `root`. On case-insensitive filesystems
/// (Windows NTFS, macOS HFS+/APFS default), `path.starts_with(root)` can
/// pass while `path.strip_prefix(root)` byte-equals fails — case skew
/// between the canonicalized project root and the indexed chunk path.
/// Centralizes the "warn-and-fall-back" shim `enumerate_files` also uses, so
/// caller sites don't silently leak absolute paths into JSON envelopes
/// documented as "relative to project root".
pub fn relativize_or_warn(file: &Path, root: &Path) -> std::path::PathBuf {
    match file.strip_prefix(root) {
        Ok(rel) => rel.to_path_buf(),
        Err(_) => {
            tracing::warn!(
                file = %file.display(),
                root = %root.display(),
                "relativize: file does not have root as prefix (case-insensitive FS skew?) — emitting absolute path"
            );
            file.to_path_buf()
        }
    }
}

// ============ Note Indexing Helper ============

/// Index notes into the database (store without embeddings)
///
/// Shared logic used by CLI commands.
/// Stores notes in the database for mention-based lookup (no note embeddings).
///
/// # Arguments
/// * `notes` - Notes to index
/// * `notes_path` - Path to notes file (for mtime tracking)
/// * `store` - Store for persisting notes
///
/// # Returns
/// Number of notes indexed
pub fn index_notes(
    notes: &[note::Note],
    notes_path: &Path,
    store: &Store<store::ReadWrite>,
) -> anyhow::Result<usize> {
    let _span =
        tracing::info_span!("index_notes", path = %notes_path.display(), count = notes.len())
            .entered();

    if notes.is_empty() {
        return Ok(0);
    }

    // Get file mtime
    let file_mtime = notes_path
        .metadata()
        .and_then(|m| m.modified())
        .map_err(|e| {
            tracing::trace!(path = %notes_path.display(), error = %e, "Failed to get file mtime");
            e
        })
        .ok()
        .and_then(|t| {
            t.duration_since(std::time::UNIX_EPOCH)
                .map_err(|e| {
                    tracing::trace!(path = %notes_path.display(), error = %e, "File mtime before Unix epoch");
                })
                .ok()
        })
        .map(duration_to_mtime_millis)
        .unwrap_or(0);

    // Atomically replace notes (delete old + insert new in single transaction)
    store.replace_notes_for_file(notes, notes_path, file_mtime)?;

    Ok(notes.len())
}

// ============ File Enumeration ============

/// Default maximum file size to index (1MB). Generated code (`bindings.rs`
/// blobs, compiled TypeScript, migrations) can exceed this and is silently
/// skipped; tune via `CQS_MAX_FILE_SIZE` (bytes) when that happens.
const DEFAULT_MAX_FILE_SIZE: u64 = 1_048_576;

/// Resolve the per-file size cap, checking `CQS_MAX_FILE_SIZE` first. Zero
/// falls back to the default — disabling the cap entirely would OOM on
/// multi-GB artifacts.
///
/// `pub` so the worktree overlay's parse-set filter
/// (`src/worktree_overlay.rs`) gates delta files by the exact same
/// discovery cap `cqs index` applies, rather than a near-equivalent.
pub fn max_file_size() -> u64 {
    std::env::var("CQS_MAX_FILE_SIZE")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .filter(|&n| n > 0)
        .unwrap_or(DEFAULT_MAX_FILE_SIZE)
}

/// Enumerate files to index in a project directory.
///
/// Respects `.gitignore` and `.cqsignore` (additive on top of `.gitignore`,
/// both disabled by `no_ignore=true`); skips hidden files and files larger
/// than `CQS_MAX_FILE_SIZE` bytes (default 1 MiB — generated code can
/// exceed this). Returns relative paths from the project root.
///
/// Backed by [`enumerate_files_iter`] — this is a `.collect::<Vec<_>>()`
/// wrapper that materializes the whole walk into a single allocation.
/// Callers that don't need the count or random access (`run_daemon_reconcile`,
/// 1k-file streaming queries) should consume the iterator directly to keep
/// peak heap bounded by batch size rather than tree size.
pub fn enumerate_files(
    root: &Path,
    extensions: &[&str],
    no_ignore: bool,
) -> anyhow::Result<Vec<PathBuf>> {
    let iter = enumerate_files_iter(root, extensions, no_ignore)?;
    let files: Vec<PathBuf> = iter.collect();
    tracing::info!(file_count = files.len(), "File enumeration complete");
    Ok(files)
}

/// Streaming variant of [`enumerate_files`]. Returns an iterator of
/// project-relative paths under `root`, applying the same `.gitignore` /
/// `.cqsignore` / size-cap / hidden-file filters as the eager wrapper.
///
/// `extensions` is borrowed and converted to owned `Vec<String>` so the
/// returned iterator outlives the call and can be threaded into
/// long-lived loops (the watch reconcile path is the canonical consumer).
///
/// Failures in the walk surface via `tracing::warn!` (first three) /
/// `tracing::debug!` (rest). The iterator yields *only* the paths that successfully
/// passed every filter — operators inspecting the walk count vs `find`
/// output can grep journalctl for the "Skipping …" lines if the counts
/// disagree.
///
/// Memory: O(WalkBuilder internals + per-iteration scratch). The
/// `ignore` crate keeps a small per-directory ignore stack; everything
/// else is stack-local. Peak heap is independent of tree size.
pub fn enumerate_files_iter(
    root: &Path,
    extensions: &[&str],
    no_ignore: bool,
) -> anyhow::Result<impl Iterator<Item = PathBuf>> {
    let _span = tracing::debug_span!("enumerate_files_iter", root = %root.display()).entered();
    use anyhow::Context;
    use ignore::WalkBuilder;

    let root = dunce::canonicalize(root).context("Failed to canonicalize root")?;

    // `.cqsignore` layers on top of `.gitignore` for cqs-specific exclusions
    // (vendored minified JS, large data fixtures, etc.) — files we want
    // committed to git but don't want indexed. Same gitignore syntax,
    // hierarchical (per-directory), and respected by both `cqs index` (here)
    // and `cqs watch` (see `cli/watch.rs::build_gitignore_matcher`).
    // `--no-ignore` disables it alongside .gitignore.
    // DoS rails: bound the walk's recursion depth and the number of files it
    // can yield. An adversarial or pathological tree (deep nesting, symlink
    // farms — symlinks are never followed, but a real directory layout can
    // still nest arbitrarily — or millions of matching files) would otherwise
    // make every index / reconcile walk unbounded. These are generous
    // ceilings, not tuning knobs; no real source tree approaches them.
    let max_depth = crate::limits::walk_max_depth();
    let max_files = crate::limits::walk_max_files();

    let mut wb = WalkBuilder::new(&root);
    if !no_ignore {
        wb.add_custom_ignore_filename(".cqsignore");
    }
    let walker = wb
        .git_ignore(!no_ignore)
        .git_global(!no_ignore)
        .git_exclude(!no_ignore)
        .ignore(!no_ignore)
        .hidden(!no_ignore)
        .follow_links(false)
        .max_depth(Some(max_depth))
        .filter_entry(|entry| {
            // Skip nested git worktrees. A linked worktree's `.git` is a file
            // (not a directory) that contains a `gitdir: ...` pointer. Indexing
            // the worktree would duplicate the entire source tree under a
            // different prefix — this is the root cause of `.claude/worktrees/`
            // pollution in the index.
            if entry.file_type().is_some_and(|ft| ft.is_dir())
                && entry.path().join(".git").is_file()
            {
                return false;
            }
            true
        })
        .build();

    let size_cap = max_file_size();
    // When `metadata()` fails (broken symlink target, transient FS error,
    // permission flip mid-walk), surface it via the same first-3-warn-
    // then-debug shape used by the canonicalize arm below. Silently dropping
    // files leaves operators staring at fewer chunks than expected with no
    // diagnostic trail.
    //
    // Counters live in `Arc<AtomicUsize>` so the per-row closures can
    // each hold their own clone. The walker outlives the function call (it's
    // returned via `impl Iterator`), so `&AtomicUsize` lifetimes wouldn't
    // work — Arc is the cheapest move-into-closure shape.
    let metadata_failures = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
    let canonicalize_failures = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
    // One-shot guards so the depth-cap and file-cap warns fire at most once
    // per walk rather than per-entry. `yielded` counts post-filter paths so
    // the file cap bounds what the caller actually receives.
    let depth_cap_warned = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let files_cap_warned = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    let yielded = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
    // Owned ext list so the iterator doesn't borrow caller storage.
    let exts_owned: Vec<String> = extensions.iter().map(|s| (*s).to_string()).collect();
    let metadata_failures_for_filter = std::sync::Arc::clone(&metadata_failures);
    let depth_cap_warned_for_filter = std::sync::Arc::clone(&depth_cap_warned);
    let root_for_depth = root.clone();
    let exts_for_filter = exts_owned.clone();
    let root_for_filter = root.clone();
    let root_for_cap = root.clone();
    Ok(walker
        .filter_map(|e| {
            e.map_err(|err| {
                tracing::debug!(error = %err, "Failed to read directory entry during walk");
            })
            .ok()
        })
        .filter(move |e| {
            // An entry sitting exactly at the depth cap means deeper
            // descendants were pruned by `max_depth` — surface the truncation
            // once so an operator staring at a short file list knows the walk
            // was bounded rather than the tree being small.
            if e.depth() >= max_depth
                && !depth_cap_warned_for_filter.swap(true, std::sync::atomic::Ordering::Relaxed)
            {
                tracing::warn!(
                    root = %root_for_depth.display(),
                    cap = max_depth,
                    "File walk hit max-depth cap (CQS_WALK_MAX_DEPTH); deeper entries were pruned"
                );
            }
            true
        })
        .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
        .filter(move |e| match e.metadata() {
            Ok(m) => {
                let len = m.len();
                if len > size_cap {
                    // Surface skipped oversize files at info so users
                    // debugging "why doesn't my symbol show up" discover
                    // CQS_MAX_FILE_SIZE instead of a silent drop.
                    tracing::info!(
                        path = %e.path().display(),
                        size = len,
                        cap = size_cap,
                        "Skipping oversize file (CQS_MAX_FILE_SIZE)"
                    );
                    false
                } else {
                    true
                }
            }
            Err(err) => {
                // `ignore::Error::Display` already prints the underlying io
                // kind (e.g., "permission denied", "no such file or
                // directory"), so the operator gets the kind implicitly via
                // `error = %err`.
                let count = metadata_failures_for_filter
                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
                if count < 3 {
                    tracing::warn!(
                        path = %e.path().display(),
                        error = %err,
                        "Skipping file: metadata() failed"
                    );
                } else {
                    tracing::debug!(
                        path = %e.path().display(),
                        error = %err,
                        "Skipping file: metadata() failed"
                    );
                }
                false
            }
        })
        .filter(move |e| {
            // `eq_ignore_ascii_case` compares case-insensitively without
            // allocating, unlike `to_ascii_lowercase`.
            e.path()
                .extension()
                .and_then(|ext| ext.to_str())
                .map(|ext| {
                    exts_for_filter
                        .iter()
                        .any(|e| ext.eq_ignore_ascii_case(e.as_str()))
                })
                .unwrap_or(false)
        })
        .filter_map(move |e| {
            let path = match dunce::canonicalize(e.path()) {
                Ok(p) => p,
                Err(err) => {
                    let count = canonicalize_failures
                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
                    if count < 3 {
                        tracing::warn!(
                            path = %e.path().display(),
                            error = %err,
                            "Failed to canonicalize path, skipping"
                        );
                    } else {
                        tracing::debug!(
                            path = %e.path().display(),
                            error = %err,
                            "Failed to canonicalize path, skipping"
                        );
                    }
                    return None;
                }
            };
            if path.starts_with(&root_for_filter) {
                // `starts_with` and `strip_prefix` can disagree on
                // case-insensitive filesystems (NTFS, HFS+) — `Cqs` vs
                // `cqs` matches under `starts_with` but `strip_prefix`
                // does a byte-equal segment compare and refuses. Falling
                // back to the absolute path would silently leak it into
                // the relative-path workflow, breaking every downstream
                // lookup keyed by relative origin. Skipping with a warn
                // surfaces the disagreement so the operator can fix the
                // case skew (or re-canonicalize the project root).
                match path.strip_prefix(&root_for_filter) {
                    Ok(rel) => Some(rel.to_path_buf()),
                    Err(_) => {
                        tracing::warn!(
                            path = %path.display(),
                            root = %root_for_filter.display(),
                            "enumerate_files: starts_with passed but strip_prefix failed (case-insensitive filesystem?) — skipping"
                        );
                        None
                    }
                }
            } else {
                tracing::warn!(path = %e.path().display(), "Skipping path outside project");
                None
            }
        })
        // File-count DoS rail: stop the walk once `max_files` paths have been
        // yielded. `scan` returning `None` terminates the iterator, so a tree
        // with millions of matching files can't make the walk unbounded — we
        // stop reading the filesystem rather than just dropping later entries.
        .scan((), move |(), rel| {
            let prev = yielded.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            if prev >= max_files {
                if !files_cap_warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
                    tracing::warn!(
                        root = %root_for_cap.display(),
                        cap = max_files,
                        "File walk hit max-files cap (CQS_WALK_MAX_FILES); remaining entries were not enumerated"
                    );
                }
                // `scan` yields `Option<Item>`; `None` ends iteration.
                None
            } else {
                Some(rel)
            }
        }))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The iterator API yields the same set of files as the eager wrapper for
    /// a small synthetic tree. Pins the contract that
    /// `enumerate_files = enumerate_files_iter.collect()`.
    #[test]
    #[serial_test::serial(enumerate_files)]
    fn enumerate_files_iter_matches_eager_for_small_tree() {
        let dir = tempfile::tempdir().expect("tempdir");
        let src = dir.path().join("src");
        std::fs::create_dir(&src).expect("mkdir src");
        std::fs::write(src.join("a.rs"), "fn a() {}").expect("write a.rs");
        std::fs::write(src.join("b.rs"), "fn b() {}").expect("write b.rs");
        std::fs::write(src.join("c.txt"), "ignored").expect("write c.txt");

        let exts = ["rs"];
        let eager: std::collections::BTreeSet<PathBuf> = enumerate_files(dir.path(), &exts, true)
            .expect("enumerate_files")
            .into_iter()
            .collect();
        let streamed: std::collections::BTreeSet<PathBuf> =
            enumerate_files_iter(dir.path(), &exts, true)
                .expect("enumerate_files_iter")
                .collect();
        assert_eq!(
            eager, streamed,
            "iter and eager paths must yield the same files"
        );
        assert_eq!(eager.len(), 2, "expected 2 .rs files, got {eager:?}");
    }

    /// The iterator yields paths *one at a time* — calling `.next()`
    /// materializes a single PathBuf, not the whole walk. Pins the contract
    /// by counting yields against an explicit `take(N)`.
    /// Memory bounding is impractical to assert directly without a
    /// custom allocator, but this proves the iterator surface accepts
    /// partial consumption.
    #[test]
    #[serial_test::serial(enumerate_files)]
    fn enumerate_files_iter_supports_partial_consumption() {
        let dir = tempfile::tempdir().expect("tempdir");
        let src = dir.path().join("src");
        std::fs::create_dir(&src).expect("mkdir src");
        for i in 0..50 {
            std::fs::write(src.join(format!("f{i}.rs")), "fn f() {}").expect("write");
        }
        let exts = ["rs"];
        let mut iter = enumerate_files_iter(dir.path(), &exts, true).expect("iter");
        // Consume exactly 5 — the rest of the walk should never run.
        let first_five: Vec<_> = iter.by_ref().take(5).collect();
        assert_eq!(first_five.len(), 5);
        // Iterator is still alive and can produce more.
        assert!(iter.next().is_some(), "iterator must yield more on demand");
    }

    #[test]
    fn test_is_test_chunk_name_patterns() {
        // Positive: name-based
        assert!(is_test_chunk("test_foo", "src/lib.rs"));
        assert!(is_test_chunk("Test_suite", "src/lib.rs"));
        assert!(is_test_chunk("Test", "src/lib.rs")); // bare "Test" name
        assert!(is_test_chunk("foo_test", "src/lib.rs"));
        assert!(is_test_chunk("foo_test_bar", "src/lib.rs"));
        assert!(is_test_chunk("foo.test", "src/lib.rs"));
        // Negative: name-based.
        // "TestSuite", "TestHarness", "TestRegistry" etc. are NOT tests —
        // they're test-framework API types that should not be demoted in
        // search results. Only `test_` and `Test_` prefixes (with
        // underscore) are treated as test chunks by name.
        assert!(!is_test_chunk("TestSuite", "src/lib.rs"));
        assert!(!is_test_chunk("TestHarness", "src/lib.rs"));
        assert!(!is_test_chunk("TestRegistry", "src/lib.rs"));
        assert!(!is_test_chunk("search_filtered", "src/lib.rs"));
        assert!(!is_test_chunk("testing_util", "src/lib.rs"));
    }

    #[test]
    fn test_is_test_chunk_path_patterns() {
        // Positive: path-based
        assert!(is_test_chunk("helper", "tests/helper.rs"));
        assert!(is_test_chunk("helper", "src/tests/helper.rs"));
        assert!(is_test_chunk("helper", "search_test.rs"));
        assert!(is_test_chunk("helper", "search.test.ts"));
        assert!(is_test_chunk("helper", "search.spec.js"));
        assert!(is_test_chunk("helper", "search_test.go"));
        assert!(is_test_chunk("helper", "search_test.py"));
        // Negative: path-based
        assert!(!is_test_chunk("helper", "src/lib.rs"));
        assert!(!is_test_chunk("helper", "src/search.rs"));
    }

    #[test]
    fn test_is_test_chunk_combined() {
        // Both name and path match
        assert!(is_test_chunk("test_helper", "tests/helper.rs"));
        // Name matches, path doesn't
        assert!(is_test_chunk("test_search", "src/search.rs"));
        // Path matches, name doesn't
        assert!(is_test_chunk("setup_fixtures", "tests/fixtures.rs"));
    }

    // ─── rel_display tests ──────────────────────────────────────────────────

    #[test]
    fn test_rel_display_relative_path_within_base() {
        let root = Path::new("/home/user/project");
        let path = Path::new("/home/user/project/src/main.rs");
        assert_eq!(rel_display(path, root), "src/main.rs");
    }

    #[test]
    fn test_rel_display_path_outside_base() {
        let root = Path::new("/home/user/project");
        let path = Path::new("/tmp/other/file.rs");
        // Path outside root — returns full path with normalized separators
        assert_eq!(rel_display(path, root), "/tmp/other/file.rs");
    }

    #[test]
    fn test_rel_display_exact_base_path() {
        let root = Path::new("/home/user/project");
        let path = Path::new("/home/user/project");
        // Exact match — strip_prefix returns ""
        assert_eq!(rel_display(path, root), "");
    }

    #[test]
    fn test_rel_display_backslash_normalization() {
        // Simulate a Windows-style path stored as a PathBuf
        let root = Path::new("/home/user/project");
        let path = PathBuf::from("/home/user/project/src\\cli\\mod.rs");
        assert_eq!(rel_display(&path, root), "src/cli/mod.rs");
    }

    #[test]
    fn test_rel_display_no_common_prefix() {
        let root = Path::new("/opt/tools");
        let path = Path::new("/var/log/app.log");
        assert_eq!(rel_display(path, root), "/var/log/app.log");
    }

    // ─── normalize_path / normalize_slashes verbatim-prefix tests ─

    #[test]
    fn test_normalize_path_strips_windows_verbatim_prefix() {
        // \\?\C:\foo\bar → C:/foo/bar
        let p = Path::new(r"\\?\C:\foo\bar");
        assert_eq!(normalize_path(p), "C:/foo/bar");
    }

    #[test]
    fn test_normalize_path_strips_windows_unc_verbatim_prefix() {
        // \\?\UNC\server\share\file → //server/share/file
        let p = Path::new(r"\\?\UNC\server\share\file");
        assert_eq!(normalize_path(p), "//server/share/file");
    }

    #[test]
    fn test_normalize_path_passthrough_without_verbatim() {
        let p = Path::new("/usr/local/bin/cqs");
        assert_eq!(normalize_path(p), "/usr/local/bin/cqs");
    }

    #[test]
    fn test_normalize_slashes_strips_verbatim_prefix() {
        assert_eq!(normalize_slashes(r"\\?\C:\foo"), "C:/foo");
        assert_eq!(normalize_slashes(r"\\?\UNC\srv\sh"), "//srv/sh");
        assert_eq!(normalize_slashes("plain/unix/path"), "plain/unix/path");
    }

    // ─── index_notes tests ──────────────────────────────────────────────────

    use crate::test_helpers::setup_store;

    /// Creates a notes file in the specified directory with the given content.
    ///
    /// # Arguments
    ///
    /// * `dir` - The directory path where the notes file will be created
    /// * `content` - The content to write to the notes.toml file
    ///
    /// # Returns
    ///
    /// Returns a `PathBuf` pointing to the created notes.toml file.
    ///
    /// # Panics
    ///
    /// Panics if the file write operation fails.
    fn make_notes_file(dir: &std::path::Path, content: &str) -> PathBuf {
        let path = dir.join("notes.toml");
        std::fs::write(&path, content).unwrap();
        path
    }

    #[test]
    fn test_index_notes_empty_returns_zero() {
        let (store, dir) = setup_store();
        let notes_path = make_notes_file(dir.path(), "# empty notes file\n");
        let notes: Vec<note::Note> = Vec::new();

        let count = index_notes(&notes, &notes_path, &store).unwrap();
        assert_eq!(count, 0);

        // Verify no notes in store
        let summaries = store.list_notes_summaries().unwrap();
        assert!(summaries.is_empty());
    }

    #[test]
    fn test_index_notes_stores_notes() {
        let (store, dir) = setup_store();
        let notes_path = make_notes_file(
            dir.path(),
            r#"
[[note]]
text = "Always use RRF search, not raw embedding"
sentiment = -0.5
mentions = ["search.rs"]

[[note]]
text = "Batch queries are fast"
sentiment = 0.5
mentions = ["store.rs"]
"#,
        );

        let notes = vec![
            note::Note {
                id: "note:0".to_string(),
                text: "Always use RRF search, not raw embedding".to_string(),
                sentiment: -0.5,
                mentions: vec!["search.rs".to_string()],
                kind: None,
            },
            note::Note {
                id: "note:1".to_string(),
                text: "Batch queries are fast".to_string(),
                sentiment: 0.5,
                mentions: vec!["store.rs".to_string()],
                kind: None,
            },
        ];

        let count = index_notes(&notes, &notes_path, &store).unwrap();
        assert_eq!(count, 2);

        // Verify notes are stored
        let summaries = store.list_notes_summaries().unwrap();
        assert_eq!(summaries.len(), 2);
        assert_eq!(
            summaries[0].text,
            "Always use RRF search, not raw embedding"
        );
        assert!((summaries[0].sentiment - (-0.5)).abs() < f32::EPSILON);
        assert_eq!(summaries[1].text, "Batch queries are fast");
    }

    #[test]
    fn test_index_notes_stores_note_sentiment() {
        let (store, dir) = setup_store();
        let notes_path = make_notes_file(dir.path(), "");

        let notes = vec![note::Note {
            id: "note:0".to_string(),
            text: "Serious issue with error handling".to_string(),
            sentiment: -1.0,
            mentions: vec!["lib.rs".to_string()],
            kind: None,
        }];

        let count = index_notes(&notes, &notes_path, &store).unwrap();
        assert_eq!(count, 1);

        // Verify the note is retrievable via list_notes_summaries
        let summaries = store.list_notes_summaries().unwrap();
        assert_eq!(summaries.len(), 1);
        assert_eq!(summaries[0].text, "Serious issue with error handling");
        assert!((summaries[0].sentiment - (-1.0)).abs() < f32::EPSILON);
    }

    // ─── resolve_index_dir tests ──────────────────────────────────

    #[test]
    fn test_resolve_index_dir_only_legacy_exists() {
        let dir = tempfile::TempDir::new().unwrap();
        let legacy = dir.path().join(LEGACY_INDEX_DIR);
        std::fs::create_dir(&legacy).unwrap();

        let result = resolve_index_dir(dir.path());

        // Legacy .cq/ should have been renamed to .cqs/
        assert!(
            !legacy.exists(),
            ".cq/ should no longer exist after migration"
        );
        assert_eq!(result, dir.path().join(INDEX_DIR));
        assert!(result.exists(), ".cqs/ should exist after migration");
    }

    #[test]
    fn test_resolve_index_dir_both_exist() {
        let dir = tempfile::TempDir::new().unwrap();
        let legacy = dir.path().join(LEGACY_INDEX_DIR);
        let new = dir.path().join(INDEX_DIR);
        std::fs::create_dir(&legacy).unwrap();
        std::fs::create_dir(&new).unwrap();

        let result = resolve_index_dir(dir.path());

        // Both exist: should return .cqs/ without renaming (legacy stays)
        assert_eq!(result, new);
        assert!(legacy.exists(), ".cq/ should still exist when both present");
        assert!(new.exists(), ".cqs/ should still exist");
    }

    #[test]
    fn test_resolve_index_dir_neither_exists() {
        let dir = tempfile::TempDir::new().unwrap();

        let result = resolve_index_dir(dir.path());

        // Neither exists: should return .cqs/ path (not created, just the path)
        assert_eq!(result, dir.path().join(INDEX_DIR));
        assert!(
            !result.exists(),
            ".cqs/ should not be created, only returned as path"
        );
    }

    // ─── enumerate_files tests ────────────────────────────────────

    #[test]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_finds_supported_extensions() {
        let dir = tempfile::TempDir::new().unwrap();
        let src = dir.path().join("src");
        std::fs::create_dir(&src).unwrap();

        // Create some Rust files
        std::fs::write(src.join("main.rs"), "fn main() {}").unwrap();
        std::fs::write(src.join("lib.rs"), "pub fn lib() {}").unwrap();
        // Create a non-Rust file (should be filtered out)
        std::fs::write(src.join("readme.txt"), "hello").unwrap();

        let files = enumerate_files(dir.path(), &["rs"], false).unwrap();

        assert_eq!(files.len(), 2, "Should find exactly 2 .rs files");
        let names: Vec<String> = files
            .iter()
            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
            .collect();
        assert!(names.contains(&"main.rs".to_string()));
        assert!(names.contains(&"lib.rs".to_string()));
    }

    #[test]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_respects_cqsignore() {
        // .cqsignore should exclude matching paths from indexing, layered on
        // top of .gitignore. Verifies the indexer respects the cqs-specific
        // ignore mechanism added for vendored bundles + similar artefacts.
        let dir = tempfile::TempDir::new().unwrap();
        let src = dir.path().join("src");
        let vendor = src.join("assets").join("vendor");
        std::fs::create_dir_all(&vendor).unwrap();

        std::fs::write(src.join("main.rs"), "fn main() {}").unwrap();
        std::fs::write(vendor.join("three.min.js"), "/* huge minified */").unwrap();
        std::fs::write(vendor.join("regular.js"), "console.log('ok');").unwrap();

        // Without .cqsignore: both .js files are visible.
        let before = enumerate_files(dir.path(), &["js"], false).unwrap();
        assert_eq!(
            before.len(),
            2,
            "expected both js files visible pre-cqsignore"
        );

        // With .cqsignore excluding *.min.js, only regular.js survives.
        std::fs::write(dir.path().join(".cqsignore"), "**/*.min.js\n").unwrap();
        let after = enumerate_files(dir.path(), &["js"], false).unwrap();
        let names: Vec<String> = after
            .iter()
            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
            .collect();
        assert_eq!(after.len(), 1, "*.min.js should be filtered: got {names:?}");
        assert!(names.contains(&"regular.js".to_string()));

        // --no-ignore disables .cqsignore (same semantics as .gitignore).
        let bypassed = enumerate_files(dir.path(), &["js"], true).unwrap();
        assert_eq!(bypassed.len(), 2, "--no-ignore must also bypass .cqsignore");
    }

    #[test]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_empty_for_unsupported() {
        let dir = tempfile::TempDir::new().unwrap();

        // Create files with unsupported extensions only
        std::fs::write(dir.path().join("notes.txt"), "some text").unwrap();
        std::fs::write(dir.path().join("data.csv"), "a,b,c").unwrap();

        let files = enumerate_files(dir.path(), &["rs", "py"], false).unwrap();

        assert!(
            files.is_empty(),
            "Should return empty for directory with no supported files"
        );
    }

    /// SECURITY.md promises `follow_links=false`. A symlink to a real `.rs`
    /// file inside the project must NOT appear in the enumerated list — the
    /// walker is configured to skip links, not to dereference them. Pins the
    /// security policy.
    #[test]
    #[cfg(unix)]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_skips_symlinks_to_files() {
        use std::os::unix::fs::symlink;

        let dir = tempfile::TempDir::new().unwrap();
        // A real source file the symlink will point at.
        let real_rs = dir.path().join("real.rs");
        std::fs::write(&real_rs, "fn real() {}").unwrap();
        // A symlink alongside the real file, both `.rs`.
        let link_rs = dir.path().join("link.rs");
        symlink(&real_rs, &link_rs).unwrap();

        let files = enumerate_files(dir.path(), &["rs"], false).unwrap();
        let names: Vec<String> = files
            .iter()
            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
            .collect();

        // The real file is included; the symlink is filtered out by
        // `follow_links(false)`.
        assert!(
            names.contains(&"real.rs".to_string()),
            "real.rs should be enumerated, got {names:?}"
        );
        assert!(
            !names.contains(&"link.rs".to_string()),
            "symlink link.rs must be skipped (SEC-V1.30.1-2), got {names:?}"
        );
    }

    /// Files exceeding `CQS_MAX_FILE_SIZE` must be silently filtered. Pins
    /// the size cap behaviour.
    #[test]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_skips_oversized_files() {
        // `#[serial(enumerate_files)]` serialises this against every other
        // `enumerate_files` walk — it mutates the process-global
        // `CQS_MAX_FILE_SIZE` env var, which would otherwise race.
        let dir = tempfile::TempDir::new().unwrap();
        // Small file should pass.
        std::fs::write(dir.path().join("small.rs"), b"fn s() {}").unwrap();
        // Big file (200 bytes) exceeds the cap (100 bytes) we set below.
        std::fs::write(dir.path().join("big.rs"), vec![b'a'; 200]).unwrap();

        let prev = std::env::var("CQS_MAX_FILE_SIZE").ok();
        std::env::set_var("CQS_MAX_FILE_SIZE", "100");
        let files = enumerate_files(dir.path(), &["rs"], false).unwrap();
        // Restore env before any further work.
        match prev {
            Some(v) => std::env::set_var("CQS_MAX_FILE_SIZE", v),
            None => std::env::remove_var("CQS_MAX_FILE_SIZE"),
        }

        let names: Vec<String> = files
            .iter()
            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
            .collect();
        assert!(
            names.contains(&"small.rs".to_string()),
            "small.rs must be enumerated, got {names:?}"
        );
        assert!(
            !names.contains(&"big.rs".to_string()),
            "big.rs (200B > 100B cap) must be filtered, got {names:?}"
        );
    }

    /// The depth cap (`CQS_WALK_MAX_DEPTH`) prunes entries below the ceiling.
    /// A file nested deeper than the cap must not be enumerated; a file at or
    /// above the cap depth must. Pins the DoS rail against deep trees.
    ///
    /// `#[serial(enumerate_files)]`: this test mutates the process-global
    /// `CQS_WALK_MAX_*` env vars, so it must never run concurrently with any
    /// other `enumerate_files` walk (which would observe the cap and fail).
    #[test]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_depth_cap() {
        let dir = tempfile::TempDir::new().unwrap();
        // Shallow file at depth 1 (directly under root).
        std::fs::write(dir.path().join("shallow.rs"), b"fn s() {}").unwrap();
        // Deep file: root/a/b/c/d/deep.rs (depth 5).
        let deep_dir = dir.path().join("a").join("b").join("c").join("d");
        std::fs::create_dir_all(&deep_dir).unwrap();
        std::fs::write(deep_dir.join("deep.rs"), b"fn d() {}").unwrap();

        let prev = std::env::var("CQS_WALK_MAX_DEPTH").ok();
        // Depth cap of 2: shallow.rs (depth 1) passes; deep.rs (depth 5) pruned.
        std::env::set_var("CQS_WALK_MAX_DEPTH", "2");
        let files = enumerate_files(dir.path(), &["rs"], false).unwrap();
        match prev {
            Some(v) => std::env::set_var("CQS_WALK_MAX_DEPTH", v),
            None => std::env::remove_var("CQS_WALK_MAX_DEPTH"),
        }

        let names: Vec<String> = files
            .iter()
            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
            .collect();
        assert!(
            names.contains(&"shallow.rs".to_string()),
            "shallow.rs (depth 1) must be enumerated under depth cap 2, got {names:?}"
        );
        assert!(
            !names.contains(&"deep.rs".to_string()),
            "deep.rs (depth 5) must be pruned by depth cap 2, got {names:?}"
        );
    }

    /// The file-count cap (`CQS_WALK_MAX_FILES`) bounds how many files the walk
    /// yields. With a cap of 3 and 10 matching files, the walk must stop at 3.
    /// Pins the DoS rail against repos with millions of files.
    #[test]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_count_cap() {
        let dir = tempfile::TempDir::new().unwrap();
        for i in 0..10 {
            std::fs::write(dir.path().join(format!("f{i}.rs")), b"fn f() {}").unwrap();
        }

        let prev = std::env::var("CQS_WALK_MAX_FILES").ok();
        std::env::set_var("CQS_WALK_MAX_FILES", "3");
        let files = enumerate_files(dir.path(), &["rs"], false).unwrap();
        match prev {
            Some(v) => std::env::set_var("CQS_WALK_MAX_FILES", v),
            None => std::env::remove_var("CQS_WALK_MAX_FILES"),
        }

        assert_eq!(
            files.len(),
            3,
            "file-count cap of 3 must bound the walk to 3 yields, got {}",
            files.len()
        );
    }

    /// A file with a non-UTF8 byte sequence in its name on Linux must not
    /// crash `enumerate_files`. The walker may either skip
    /// or include it (both are defensible) but a panic is unacceptable —
    /// a hostile filename should never bring down the indexer.
    #[test]
    #[cfg(unix)]
    #[serial_test::serial(enumerate_files)]
    fn test_enumerate_files_handles_non_utf8_filename_on_unix() {
        use std::ffi::OsStr;
        use std::os::unix::ffi::OsStrExt;

        let dir = tempfile::TempDir::new().unwrap();
        // Build a filename with a `.rs` suffix preceded by non-UTF8 bytes.
        // Linux file APIs accept arbitrary bytes; UTF-8 is just a
        // convention.
        let bad_name_bytes: &[u8] = b"bad\xFF\xFEname.rs";
        let bad_path = dir.path().join(OsStr::from_bytes(bad_name_bytes));
        // Some filesystems on Linux refuse arbitrary byte sequences (NTFS
        // mounts, zfs with `-o utf8only`). Skip silently if the write
        // can't land — the test asserts panic-free behaviour, not a
        // specific filesystem capability.
        if std::fs::write(&bad_path, b"fn b() {}").is_err() {
            eprintln!("test env rejects non-UTF8 filenames; soft pass");
            return;
        }

        let files = enumerate_files(dir.path(), &["rs"], false)
            .expect("enumerate_files must not panic on non-UTF8 filenames");
        // Either included or silently skipped — both are defensible.
        // The contract under test is "no panic, returns Ok".
        assert!(
            files.len() <= 1,
            "non-UTF8 file should yield at most 1 result, got {}",
            files.len()
        );
    }
    /// Verifies that the `is_test_chunk` function correctly identifies test files based on filename patterns.
    ///
    /// # Arguments
    ///
    /// This function takes no arguments.
    ///
    /// # Returns
    ///
    /// Returns nothing. This is a test function that validates the behavior of `is_test_chunk` through assertions.
    ///
    /// # Panics
    ///
    /// Panics if any assertion fails, indicating that `is_test_chunk` does not correctly identify test files or non-test files according to expected patterns.

    #[test]
    fn is_test_chunk_spec_patterns() {
        assert!(is_test_chunk("spec_helper", "src/spec_helper.rb"));
        assert!(is_test_chunk("user_spec", "spec/user_spec.rb"));
        assert!(is_test_chunk("normal_fn", "tests/test_main.py"));
        assert!(!is_test_chunk("inspector", "src/inspect.rs"));
    }

    // _tests.rs suffix and nested /tests/ path
    #[test]
    fn is_test_chunk_tests_suffix_and_nested_path() {
        // File with _test suffix
        assert!(is_test_chunk("normal_fn", "src/search_test.rs"));
        assert!(is_test_chunk("normal_fn", "src/search_test.py"));
        // Nested /tests/ directory
        assert!(is_test_chunk("normal_fn", "src/store/tests/calls_test.rs"));
        assert!(is_test_chunk("normal_fn", "tests/integration.rs"));
        // .test. suffix (JS/TS)
        assert!(is_test_chunk("normal_fn", "src/search.test.ts"));
        assert!(is_test_chunk("normal_fn", "src/search.test.js"));
        // _test.go suffix
        assert!(is_test_chunk("normal_fn", "pkg/search_test.go"));
        // Should NOT match
        assert!(!is_test_chunk("normal_fn", "src/testing_utils.rs"));
        assert!(!is_test_chunk("normal_fn", "src/attest.rs"));
    }
}
