Skip to main content

core_api/repograph/
explore.rs

1//! `explore` — the one tool that finds things in a code graph.
2//!
3//! [`context`](crate::repograph::context), [`impact`](crate::repograph::impact)
4//! and [`owners`](crate::repograph::owners) each answer a different question
5//! about the same target, and an assistant asking any of them almost always
6//! wants at least one of the others: what is this, what breaks if I change it,
7//! who do I ask about it. Three tools meant three schemas to find, three calls
8//! to make, and three chances to reach for `grep` instead — and a host that
9//! defers tool schemas makes *being found* the thing that decides whether a
10//! tool is used at all.
11//!
12//! So this composes them behind one name and one target, with a [`Depth`]
13//! saying how much of the answer to compute. Nothing here reads the graph
14//! itself: every fact comes from the three functions above, so an answer is the
15//! same whichever door it came through, and the cost of a depth is exactly the
16//! cost of the calls it makes.
17//!
18//! # Depth
19//!
20//! - [`Depth::Context`] — the definition, its callers and callees, what imports
21//!   it and what it changes with. The default, and the cheapest.
22//! - [`Depth::Impact`] — that, plus the blast radius of the *file* the target
23//!   is defined in.
24//! - [`Depth::History`] — that, plus who owns the file and what changes with it.
25//! - [`Depth::All`] — all three.
26//!
27//! Each of the last three is the context answer *and* its own addition, because
28//! a blast radius without the definition it belongs to is a list of paths.
29
30use crate::db::GraphDb;
31use crate::repograph::context::{context_with, ContextOptions, ContextReport};
32use crate::repograph::impact::{impact, ImpactOptions, ImpactReport};
33use crate::repograph::owners::{owners, OwnersReport};
34use crate::repograph::render::sanitize;
35use core_storage::fs::Fs;
36use serde::Serialize;
37use std::collections::BTreeSet;
38use std::path::Path;
39
40/// How much of the answer one `explore` call computes.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "snake_case")]
43pub enum Depth {
44    Context,
45    Impact,
46    History,
47    All,
48}
49
50impl Depth {
51    /// The depth a caller named, or `None` when it named none of the four.
52    ///
53    /// Exact, lower-case names only: the MCP schema and the CLI both offer the
54    /// four as an enumeration, so anything else is a caller that guessed, and
55    /// silently serving the wrong depth would be worse than saying so.
56    #[must_use]
57    pub fn parse(s: &str) -> Option<Depth> {
58        match s {
59            "context" => Some(Depth::Context),
60            "impact" => Some(Depth::Impact),
61            "history" => Some(Depth::History),
62            "all" => Some(Depth::All),
63            _ => None,
64        }
65    }
66
67    /// The four names, in the order they are offered, for an error message and
68    /// for the schemas that enumerate them.
69    pub const NAMES: [&'static str; 4] = ["context", "impact", "history", "all"];
70
71    fn wants_impact(self) -> bool {
72        matches!(self, Depth::Impact | Depth::All)
73    }
74
75    fn wants_history(self) -> bool {
76        matches!(self, Depth::History | Depth::All)
77    }
78}
79
80/// One target, from as many sides as the [`Depth`] asked for.
81#[derive(Debug, Clone, PartialEq, Serialize)]
82pub struct ExploreReport {
83    /// The target as the caller wrote it, sanitized.
84    pub target: String,
85    pub depth: Depth,
86    /// Always computed: every depth includes the context answer.
87    pub context: ContextReport,
88    /// What changing the target's *file* reaches. [`Depth::Impact`] and
89    /// [`Depth::All`], and only when the target resolved to a file at all.
90    pub impact: Option<ImpactReport>,
91    /// Who has written the target's file. [`Depth::History`] and [`Depth::All`].
92    pub owners: Option<OwnersReport>,
93    /// `(file, co-change score)`, strongest first — the context report's own
94    /// partners, copied rather than recomputed. [`Depth::History`] and
95    /// [`Depth::All`].
96    pub partners: Vec<(String, f64)>,
97}
98
99/// Everything `depth` asks for about `target`.
100///
101/// `repo` and `full` are [`context_with`]'s: the working tree the body is
102/// quoted from, and whether to quote one at all. Every other answer is the
103/// graph's alone.
104///
105/// A target that resolves to nothing — an unknown name, or an ambiguous bare
106/// one — has no file behind it, so no depth adds anything: the answer is the
107/// context report, which is the one that says *why* there is nothing else.
108#[must_use]
109pub fn explore<F: Fs>(
110    db: &GraphDb<F>,
111    repo: Option<&Path>,
112    target: &str,
113    depth: Depth,
114    full: bool,
115) -> ExploreReport {
116    let context = context_with(db, repo, target, &ContextOptions { source: full });
117    let mut report = ExploreReport {
118        target: sanitize(target),
119        depth,
120        context,
121        impact: None,
122        owners: None,
123        partners: Vec::new(),
124    };
125    // The file the target is, or the file the target's symbol is defined in.
126    // Empty when nothing answered to the target.
127    let file = report.context.file.clone();
128    if file.is_empty() {
129        return report;
130    }
131    if depth.wants_impact() {
132        // No `modified` set: nothing here is a diff, so no partner is one the
133        // caller already has open.
134        report.impact = Some(impact(
135            db,
136            std::slice::from_ref(&file),
137            &BTreeSet::new(),
138            &ImpactOptions::default(),
139        ));
140    }
141    if depth.wants_history() {
142        report.owners = owners(db, &file, None);
143        report.partners = report.context.partners.clone();
144    }
145    report
146}