Skip to main content

comindly_core/
answers.rs

1//! What the read commands answer, computed the same way on this machine and on a server, so a
2//! command run with `--server` prints what a local run would.
3
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5
6use serde::{Deserialize, Serialize};
7
8use crate::conventions::{Convention, Manual, ORG};
9use crate::graph::{CodeGraph, ContractView, Node};
10use crate::ledger::Entry;
11use crate::manifests::InternalDep;
12use crate::model::{Edge, Symbol};
13use crate::search::SearchHit;
14
15/// `find`: symbols whose name, descriptor or path matches, in `repos` when given.
16pub fn find(g: &CodeGraph, query: &str, repos: &[String]) -> Vec<Node> {
17    // Over-fetch when filtering by repo so a full page survives the filter.
18    let mut hits = g.find(query, if repos.is_empty() { 30 } else { 200 });
19    if !repos.is_empty() {
20        hits.retain(|n| in_repos(&n.repo, repos));
21        hits.truncate(30);
22    }
23    hits
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Search {
28    pub hits: Vec<SearchHit>,
29    pub markdown: String,
30}
31
32/// How many hits to ask the ranker for; more when they are filtered by repo afterwards.
33pub fn search_limit(repos: &[String]) -> usize {
34    if repos.is_empty() {
35        12
36    } else {
37        60
38    }
39}
40
41/// `search`: ranked hits, kept to `repos` when given.
42pub fn search(query: &str, mut hits: Vec<SearchHit>, repos: &[String]) -> Search {
43    if !repos.is_empty() {
44        hits.retain(|h| in_repos(&h.repo, repos));
45        hits.truncate(12);
46    }
47    Search {
48        markdown: crate::search::markdown(query, &hits),
49        hits,
50    }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Why {
55    pub name: String,
56    pub markdown: String,
57}
58
59/// `why`: the ledger about `focus` and what it contains.
60pub fn why(g: &CodeGraph, entries: &[Entry], focus: &str) -> Option<Why> {
61    let (name, ids) = g.scope_of(focus)?;
62    let about = crate::ledger::about(entries, &ids);
63    Some(Why {
64        markdown: crate::ledger::markdown(&format!("why `{name}`"), &about),
65        name,
66    })
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct Ledger {
71    pub markdown: String,
72    /// Said instead of a report, when there is nothing to report.
73    pub note: Option<String>,
74    /// With `check`: a constraint that must hold is violated.
75    pub failed: bool,
76}
77
78/// `ledger`: the records, or the inbox of those needing attention; with `check`, constraint checks.
79pub fn ledger(
80    mut entries: Vec<Entry>,
81    symbols: &[Symbol],
82    edges: &[Edge],
83    packages: &[crate::manifests::PackageRow],
84    inbox: bool,
85    check: bool,
86) -> Ledger {
87    let findings = crate::checks::run(&entries, symbols, edges, packages);
88    if check {
89        if findings.is_empty() {
90            return Ledger {
91                note: Some("no constraint carries a check (add one with --field check=...)".into()),
92                ..Default::default()
93            };
94        }
95        return Ledger {
96            markdown: format!(
97                "## Constraint checks\n\n{}",
98                crate::checks::markdown(&findings, None)
99            ),
100            failed: findings
101                .iter()
102                .any(|f| f.severity == "must" && !f.violations.is_empty()),
103            note: None,
104        };
105    }
106    crate::checks::promote(&mut entries, &findings);
107    let shown = crate::ledger::inbox(&entries, inbox);
108    let title = if inbox { "Ledger inbox" } else { "Ledger" };
109    let mut markdown = format!("{}\n", crate::ledger::markdown(title, &shown));
110    let revived = crate::checks::revivals(&entries, packages);
111    if !revived.is_empty() {
112        markdown.push_str("## Retry conditions met\n\n");
113        for (e, why) in revived {
114            markdown.push_str(&format!("- {} — {why}\n", crate::ledger::line(e)));
115        }
116    }
117    Ledger {
118        markdown,
119        ..Default::default()
120    }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Flow {
125    pub focus: String,
126    /// The stored walkthrough and the questions it answers, when one was written.
127    pub narration: Option<(String, Vec<String>)>,
128    pub steps: Vec<Step>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct Step {
133    pub depth: usize,
134    pub via: String,
135    pub name: String,
136    pub location: String,
137}
138
139/// `flow`: the walkthrough written for `focus`, if any, and its live call trace.
140pub fn flow(
141    g: &CodeGraph,
142    focus: &str,
143    narration: impl FnOnce(&str) -> Option<(String, Vec<String>)>,
144) -> Option<Flow> {
145    let idx = g.lookup(focus)?;
146    let narration = g.zoom(idx).focus.and_then(|n| narration(&n.id));
147    let steps = g
148        .thread(idx, 4)
149        .into_iter()
150        .map(|h| Step {
151            depth: h.depth,
152            via: h.via.to_string(),
153            name: h.node.name,
154            location: h.node.location,
155        })
156        .collect();
157    Some(Flow {
158        focus: focus.to_string(),
159        narration,
160        steps,
161    })
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct Contracts {
166    /// Every contract, before `kind` and `dangling` narrow the list.
167    pub total: usize,
168    pub views: Vec<ContractView>,
169}
170
171pub fn contracts(g: &CodeGraph, kind: Option<&str>, dangling: bool) -> Contracts {
172    Contracts {
173        total: g.contracts(None, false).len(),
174        views: g.contracts(kind, dangling),
175    }
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct Conventions {
180    pub conventions: Vec<Convention>,
181    pub hidden: Vec<Manual>,
182}
183
184/// `conventions`: the conventions in force (measured, edited and the team's) and the hidden ones,
185/// for `repo` and the org baseline when a repo is given.
186pub fn conventions(all: Vec<Convention>, manual: &[Manual], repo: Option<&str>) -> Conventions {
187    let here = |r: &str| repo.is_none_or(|want| r == want || r == ORG);
188    Conventions {
189        conventions: all.into_iter().filter(|c| here(&c.repo)).collect(),
190        hidden: manual
191            .iter()
192            .filter(|m| m.hidden && here(&m.repo))
193            .cloned()
194            .collect(),
195    }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct Explore {
200    pub symbols: usize,
201    pub edges: usize,
202    pub dangling: usize,
203    /// `None` when no symbol matches the focus.
204    pub view: Option<ExploreView>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct ExploreView {
209    pub focus: Option<Node>,
210    pub container: Option<Node>,
211    pub callees: Vec<Node>,
212    pub callers: Vec<Node>,
213    pub importers: Vec<Node>,
214    pub members: Vec<Node>,
215    /// Dependents within the blast radius, and how many per repo.
216    pub dependents: usize,
217    pub by_repo: BTreeMap<String, usize>,
218    pub budget: usize,
219    /// The context pack: symbols to read with their estimated tokens.
220    pub pack: Vec<(Node, usize)>,
221}
222
223/// Hops the blast radius follows, and the context pack's token budget.
224pub const RIPPLE_DEPTH: usize = 4;
225pub const PACK_BUDGET: usize = 1500;
226
227/// `explore`: zoom, blast radius and context pack for `focus`.
228pub fn explore(g: &CodeGraph, focus: &str) -> Explore {
229    let view = g.lookup(focus).map(|idx| {
230        let z = g.zoom(idx);
231        let hits = g.ripple(idx, RIPPLE_DEPTH);
232        let mut by_repo = BTreeMap::new();
233        for h in &hits {
234            *by_repo.entry(h.node.repo.clone()).or_default() += 1;
235        }
236        ExploreView {
237            focus: z.focus,
238            container: z.container,
239            callees: z.callees,
240            callers: z.callers,
241            importers: z.importers,
242            members: z.members,
243            dependents: hits.len(),
244            by_repo,
245            budget: PACK_BUDGET,
246            pack: g.context_pack(idx, PACK_BUDGET),
247        }
248    });
249    Explore {
250        symbols: g.node_count(),
251        edges: g.edge_count(),
252        dangling: g.dangling_edges,
253        view,
254    }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct Stats {
259    pub symbols: usize,
260    /// Repos in the index; `repos` holds those reported.
261    pub indexed: usize,
262    /// Asked-for repos the index does not have.
263    pub unknown: Vec<String>,
264    pub repos: Vec<RepoStats>,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct RepoStats {
269    pub repo: String,
270    pub symbols: usize,
271    pub edges: usize,
272    pub enriched: usize,
273    pub kinds: BTreeMap<String, usize>,
274}
275
276/// `stats`: per repo (`want`, else all) symbols, edges, kinds and how many have LLM summaries.
277pub fn stats(
278    symbols: &[Symbol],
279    edges: &[Edge],
280    enriched: &HashSet<String>,
281    want: &[String],
282) -> Stats {
283    let all: BTreeSet<&str> = symbols.iter().map(|s| s.id.package.as_str()).collect();
284    let (known, unknown): (Vec<&String>, Vec<&String>) =
285        want.iter().partition(|r| all.contains(r.as_str()));
286    let repos: Vec<&str> = if want.is_empty() {
287        all.iter().copied().collect()
288    } else {
289        known.into_iter().map(String::as_str).collect()
290    };
291    let repos = repos
292        .into_iter()
293        .map(|repo| {
294            let rsyms: Vec<&Symbol> = symbols.iter().filter(|s| s.id.package == repo).collect();
295            let mut kinds = BTreeMap::new();
296            for s in &rsyms {
297                *kinds.entry(format!("{:?}", s.kind)).or_default() += 1;
298            }
299            RepoStats {
300                repo: repo.to_string(),
301                symbols: rsyms.len(),
302                edges: edges.iter().filter(|e| e.src.package == repo).count(),
303                enriched: rsyms
304                    .iter()
305                    .filter(|s| enriched.contains(&s.id.render()))
306                    .count(),
307                kinds,
308            }
309        })
310        .collect();
311    Stats {
312        symbols: symbols.len(),
313        indexed: all.len(),
314        unknown: unknown.into_iter().cloned().collect(),
315        repos,
316    }
317}
318
319/// `packages`: internal dependencies, only the skewed ones with `skew`.
320pub fn packages(deps: Vec<InternalDep>, skew: bool) -> Vec<InternalDep> {
321    deps.into_iter().filter(|d| !skew || d.skewed()).collect()
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Preflight {
326    pub markdown: String,
327    /// A `must` constraint check fails in a changed file.
328    pub blocking: bool,
329}
330
331/// `preflight`: what is known about the code `changes` touch; the changes come from git.
332pub fn preflight(
333    g: &CodeGraph,
334    symbols: &[Symbol],
335    edges: &[Edge],
336    mut entries: Vec<Entry>,
337    recipes: &[Convention],
338    packages: &[crate::manifests::PackageRow],
339    changes: &[crate::preflight::Change],
340) -> Preflight {
341    let findings = crate::checks::run(&entries, symbols, edges, packages);
342    crate::checks::promote(&mut entries, &findings);
343    let markdown =
344        crate::preflight::report(g, symbols, edges, &entries, recipes, &findings, changes);
345    let changed: Vec<String> = changes
346        .iter()
347        .map(|c| format!("{}/{}", c.repo, c.path))
348        .collect();
349    let blocking = findings.iter().any(|f| {
350        f.severity == "must"
351            && f.violations
352                .iter()
353                .any(|v| changed.contains(&format!("{}/{}", v.repo, v.path)))
354    });
355    Preflight { markdown, blocking }
356}
357
358fn in_repos(repo: &str, repos: &[String]) -> bool {
359    repos.iter().any(|r| repo.eq_ignore_ascii_case(r))
360}