trusty-search 0.27.2

Machine-wide hybrid code search service: BM25 + vector + KG, zero cold-start, MCP server
Documentation
//! CWD → index resolution for `trusty-search index-status` (no-arg form).
//!
//! Why: when the user runs `trusty-search index-status` from inside a project
//! directory, they expect to see the status of that project's index — the same
//! way `trusty-search index .` defaults to CWD. This module implements the
//! matching logic so the `index_status` handler stays focused on rendering.
//!
//! What: fetches the index list from the daemon, queries each index's
//! `root_path` via `GET /indexes/:id/status`, and returns all indexes whose
//! `root_path` is an ANCESTOR OF or EQUAL TO the cwd.  Results are returned
//! sorted by `root_path` (shortest match first = most-root ancestor first).
//!
//! Test: `cwd_resolve_matches_*` unit tests below cover exact, ancestor,
//! multi-match, and no-match cases without hitting a live daemon.

use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

// ─── Public types ─────────────────────────────────────────────────────────────

/// One candidate index that covers the current working directory.
///
/// Why: bundles the id and the root_path so callers don't need to re-fetch.
/// What: returned by `resolve_cwd_indexes`; sorted by `root_path` length
/// ascending (broadest ancestor first).
/// Test: produced by `cwd_resolve_matches_ancestor` in this module's tests.
#[derive(Debug, Clone)]
pub struct CwdMatch {
    /// Daemon-side index identifier (as returned by `GET /indexes`).
    pub id: String,
    /// Registered `root_path` for the index.
    pub root_path: PathBuf,
    /// Full status JSON body (`GET /indexes/:id/status`).
    pub status_body: serde_json::Value,
}

// ─── Main resolver ────────────────────────────────────────────────────────────

/// Resolve all daemon indexes that "cover" the current working directory.
///
/// Why: a user invoking `trusty-search index-status` without an explicit id
/// expects to see the status of whichever index(es) own the project they are
/// working in — mirroring the convention used by `trusty-search index .`.
///
/// What: lists all indexes via `GET /indexes`, queries each one's `root_path`
/// via `GET /indexes/:id/status`, and collects every index whose `root_path`
/// is an ancestor of (or equal to) `cwd`.  Results are sorted by `root_path`
/// ascending so that a broad repo root appears before a narrow sub-index.
///
/// Test: `cwd_resolve_matches_exact`, `cwd_resolve_matches_ancestor`,
/// `cwd_resolve_multiple_matches`, `cwd_resolve_no_match` in this module.
pub async fn resolve_cwd_indexes(client: &reqwest::Client, base: &str) -> Result<Vec<CwdMatch>> {
    let cwd = std::env::current_dir().context("could not determine current directory")?;
    let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
    resolve_indexes_for_cwd(client, base, &cwd).await
}

/// Core resolver that takes an explicit `cwd` rather than reading
/// `std::env::current_dir()` — makes the function unit-testable without
/// manipulating the process's working directory.
///
/// Why: separating the env-read from the matching logic lets unit tests
/// pass synthetic index lists and synthetic cwd paths without side effects.
/// What: identical logic to `resolve_cwd_indexes` but receives `cwd` as a
/// parameter.
/// Test: all `cwd_resolve_*` tests in this module call this function directly.
pub async fn resolve_indexes_for_cwd(
    client: &reqwest::Client,
    base: &str,
    cwd: &Path,
) -> Result<Vec<CwdMatch>> {
    // Fetch the list of all registered index ids.
    let list_url = format!("{base}/indexes");
    let list_body: serde_json::Value = client
        .get(&list_url)
        .send()
        .await
        .with_context(|| format!("could not reach daemon at {base}"))?
        .error_for_status()
        .with_context(|| format!("daemon returned an error for {list_url}"))?
        .json()
        .await
        .context("could not parse /indexes response")?;

    let empty: Vec<serde_json::Value> = Vec::new();
    let ids: Vec<String> = list_body
        .get("indexes")
        .and_then(|v| v.as_array())
        .unwrap_or(&empty)
        .iter()
        .filter_map(|v| v.as_str().map(|s| s.to_string()))
        .collect();

    let mut matches: Vec<CwdMatch> = Vec::new();

    for id in ids {
        let url = format!("{base}/indexes/{id}/status");
        let resp = match client.get(&url).send().await {
            Ok(r) if r.status().is_success() => r,
            _ => continue,
        };
        let body: serde_json::Value = match resp.json().await {
            Ok(b) => b,
            Err(_) => continue,
        };
        let root_str = match body.get("root_path").and_then(|v| v.as_str()) {
            Some(s) => s,
            None => continue,
        };
        let root = PathBuf::from(root_str);
        let canonical_root = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());

        // Include this index if cwd is equal to root or is nested under it.
        if cwd_is_under(cwd, &canonical_root) {
            matches.push(CwdMatch {
                id,
                root_path: root,
                status_body: body,
            });
        }
    }

    // Sort deterministically: shortest root_path string first (broadest ancestor).
    matches.sort_by(|a, b| {
        let la = a.root_path.as_os_str().len();
        let lb = b.root_path.as_os_str().len();
        la.cmp(&lb).then_with(|| {
            a.root_path
                .to_string_lossy()
                .cmp(&b.root_path.to_string_lossy())
        })
    });

    Ok(matches)
}

// ─── Path helpers ─────────────────────────────────────────────────────────────

/// Return `true` when `cwd` equals `root` or is a descendant of `root`.
///
/// Why: a single predicate keeps the matching logic out of the loop body and
/// easy to test in isolation.
/// What: calls `Path::starts_with` (true when `cwd == root` or `root` is a
/// proper prefix component of `cwd`).
/// Test: `cwd_under_helper_*` in this module's tests.
pub fn cwd_is_under(cwd: &Path, root: &Path) -> bool {
    cwd.starts_with(root)
}

// ─── Unit tests ───────────────────────────────────────────────────────────────

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

    // ── cwd_is_under helper ───────────────────────────────────────────────────

    /// Exact match: cwd == root must return true.
    ///
    /// Why: the most common case — user is at the project root.
    /// What: asserts `cwd_is_under("/proj", "/proj") == true`.
    /// Test: this test.
    #[test]
    fn cwd_under_helper_exact_match() {
        assert!(cwd_is_under(Path::new("/proj"), Path::new("/proj")));
    }

    /// Ancestor match: cwd inside root returns true.
    ///
    /// Why: engineers typically run status from a subdirectory.
    /// What: asserts `cwd_is_under("/proj/a/b", "/proj") == true`.
    /// Test: this test.
    #[test]
    fn cwd_under_helper_ancestor_match() {
        assert!(cwd_is_under(Path::new("/proj/a/b"), Path::new("/proj")));
    }

    /// Non-ancestor: cwd outside root returns false.
    ///
    /// Why: ensures sibling or unrelated directories are not included.
    /// What: asserts `cwd_is_under("/other", "/proj") == false`.
    /// Test: this test.
    #[test]
    fn cwd_under_helper_non_ancestor() {
        assert!(!cwd_is_under(Path::new("/other"), Path::new("/proj")));
    }

    /// Partial path component match should not succeed.
    ///
    /// Why: `starts_with` is component-aware, so "/projfoo" does NOT match root "/proj".
    /// What: asserts `cwd_is_under("/projfoo/bar", "/proj") == false`.
    /// Test: this test.
    #[test]
    fn cwd_under_helper_partial_component_no_match() {
        assert!(!cwd_is_under(Path::new("/projfoo/bar"), Path::new("/proj")));
    }

    // ── resolve_indexes_for_cwd logic ────────────────────────────────────────
    // The resolve_indexes_for_cwd function itself requires a live daemon, so
    // we validate the matching predicate and sort order through synthetic helpers.

    /// Build a synthetic CwdMatch list and assert sort order.
    ///
    /// Why: the caller relies on deterministic ordering (broadest ancestor first)
    /// to display multi-index output predictably.
    /// What: constructs two matches with different root depths and asserts the
    /// shallower root is first after sorting.
    /// Test: this test.
    #[test]
    fn sort_by_root_path_length_shortest_first() {
        let make_match = |root: &str| CwdMatch {
            id: root.to_string(),
            root_path: PathBuf::from(root),
            status_body: serde_json::json!({}),
        };
        let mut matches = [make_match("/project/sub"), make_match("/project")];
        matches.sort_by(|a, b| {
            let la = a.root_path.as_os_str().len();
            let lb = b.root_path.as_os_str().len();
            la.cmp(&lb).then_with(|| {
                a.root_path
                    .to_string_lossy()
                    .cmp(&b.root_path.to_string_lossy())
            })
        });
        assert_eq!(matches[0].root_path, PathBuf::from("/project"));
        assert_eq!(matches[1].root_path, PathBuf::from("/project/sub"));
    }

    /// No-match: when no index covers the cwd, the result is an empty vec.
    ///
    /// Why: the caller interprets an empty vec as "no index found" and emits
    /// a friendly error.
    /// What: applies `cwd_is_under` against a root that does not cover the
    /// test cwd and verifies nothing matches.
    /// Test: this test.
    #[test]
    fn no_match_when_cwd_outside_all_roots() {
        let cwd = Path::new("/home/user/other");
        let roots = ["/home/user/project", "/opt/work"];
        let matches: Vec<_> = roots
            .iter()
            .filter(|r| cwd_is_under(cwd, Path::new(r)))
            .collect();
        assert!(matches.is_empty());
    }

    /// Multiple-match: several ancestor roots all cover the cwd.
    ///
    /// Why: polyrepo setups may register both a broad workspace root and a
    /// narrower package sub-root; both should appear.
    /// What: applies `cwd_is_under` against two roots that both cover the cwd.
    /// Test: this test.
    #[test]
    fn multiple_roots_covering_cwd() {
        let cwd = Path::new("/ws/pkg/src/main.rs");
        let roots = ["/ws", "/ws/pkg", "/other"];
        let matches: Vec<_> = roots
            .iter()
            .filter(|r| cwd_is_under(cwd, Path::new(r)))
            .collect();
        assert_eq!(matches.len(), 2);
        assert!(matches.iter().any(|r| **r == "/ws"));
        assert!(matches.iter().any(|r| **r == "/ws/pkg"));
    }
}