1use std::collections::{BTreeMap, BTreeSet, HashSet};
5
6use crate::conventions::{Convention, Manual, ORG};
7use crate::graph::{CodeGraph, Node};
8use crate::ledger::Entry;
9use crate::manifests::InternalDep;
10use crate::model::{Edge, Symbol};
11use crate::search::SearchHit;
12
13pub fn find(g: &CodeGraph, query: &str, repos: &[String]) -> Vec<Node> {
15 let mut hits = g.find(query, if repos.is_empty() { 30 } else { 200 });
17 if !repos.is_empty() {
18 hits.retain(|n| in_repos(&n.repo, repos));
19 hits.truncate(30);
20 }
21 hits
22}
23
24pub use comindly_api::answers::{
25 Contracts, Conventions, Explore, ExploreView, Flow, Ledger, Preflight, RepoStats, Search,
26 Stats, Step, Why,
27};
28
29pub fn search_limit(repos: &[String]) -> usize {
31 if repos.is_empty() {
32 12
33 } else {
34 60
35 }
36}
37
38pub fn search(query: &str, mut hits: Vec<SearchHit>, repos: &[String]) -> Search {
40 if !repos.is_empty() {
41 hits.retain(|h| in_repos(&h.repo, repos));
42 hits.truncate(12);
43 }
44 Search {
45 markdown: crate::search::markdown(query, &hits),
46 hits,
47 }
48}
49
50pub fn why(g: &CodeGraph, entries: &[Entry], focus: &str) -> Option<Why> {
52 let (name, ids, n) = g.scopes_of(focus)?;
53 let about = crate::ledger::about(entries, &ids);
54 Some(Why {
55 markdown: crate::ledger::markdown(&why_title(&name, n), &about),
56 name,
57 })
58}
59
60pub fn why_title(name: &str, n: usize) -> String {
62 if n > 1 {
63 format!("why `{name}` ({n} symbols of that name; pass an id from `find` for one)")
64 } else {
65 format!("why `{name}`")
66 }
67}
68
69pub fn ledger(
71 mut entries: Vec<Entry>,
72 symbols: &[Symbol],
73 edges: &[Edge],
74 packages: &[crate::manifests::PackageRow],
75 inbox: bool,
76 check: bool,
77) -> Ledger {
78 let findings = crate::checks::run(&entries, symbols, edges, packages);
79 if check {
80 if findings.is_empty() {
81 return Ledger {
82 note: Some("no constraint carries a check (add one with --field check=...)".into()),
83 ..Default::default()
84 };
85 }
86 return Ledger {
87 markdown: format!(
88 "## Constraint checks\n\n{}",
89 crate::checks::markdown(&findings, None)
90 ),
91 failed: findings
92 .iter()
93 .any(|f| f.severity == "must" && !f.violations.is_empty()),
94 note: None,
95 };
96 }
97 crate::checks::promote(&mut entries, &findings);
98 let shown = crate::ledger::inbox(&entries, inbox);
99 let title = if inbox { "Ledger inbox" } else { "Ledger" };
100 let mut markdown = format!("{}\n", crate::ledger::markdown(title, &shown));
101 let revived = crate::checks::revivals(&entries, packages);
102 if !revived.is_empty() {
103 markdown.push_str("## Retry conditions met\n\n");
104 for (e, why) in revived {
105 markdown.push_str(&format!("- {} — {why}\n", crate::ledger::line(e)));
106 }
107 }
108 Ledger {
109 markdown,
110 ..Default::default()
111 }
112}
113
114pub fn flow(
116 g: &CodeGraph,
117 focus: &str,
118 narration: impl FnOnce(&str) -> Option<(String, Vec<String>)>,
119) -> Option<Flow> {
120 let idx = g.lookup(focus)?;
121 let narration = g.zoom(idx).focus.and_then(|n| narration(&n.id));
122 let steps = g
123 .thread(idx, 4)
124 .into_iter()
125 .map(|h| Step {
126 depth: h.depth,
127 via: h.via.to_string(),
128 name: h.node.name,
129 location: h.node.location,
130 })
131 .collect();
132 Some(Flow {
133 focus: focus.to_string(),
134 narration,
135 steps,
136 })
137}
138
139pub fn contracts(g: &CodeGraph, kind: Option<&str>, dangling: bool) -> Contracts {
140 Contracts {
141 total: g.contracts(None, false).len(),
142 views: g.contracts(kind, dangling),
143 }
144}
145
146pub fn conventions(all: Vec<Convention>, manual: &[Manual], repo: Option<&str>) -> Conventions {
149 let here = |r: &str| repo.is_none_or(|want| r == want || r == ORG);
150 Conventions {
151 conventions: all.into_iter().filter(|c| here(&c.repo)).collect(),
152 hidden: manual
153 .iter()
154 .filter(|m| m.hidden && here(&m.repo))
155 .cloned()
156 .collect(),
157 }
158}
159
160pub use comindly_api::answers::{Hook, RIPPLE_DEPTH};
161pub const PACK_BUDGET: usize = 1500;
162
163pub fn explore(g: &CodeGraph, focus: &str) -> Explore {
165 let view = g.lookup(focus).map(|idx| {
166 let z = g.zoom(idx);
167 let hits = g.ripple(idx, RIPPLE_DEPTH);
168 let mut by_repo = BTreeMap::new();
169 for h in &hits {
170 *by_repo.entry(h.node.repo.clone()).or_default() += 1;
171 }
172 ExploreView {
173 focus: z.focus,
174 container: z.container,
175 callees: z.callees,
176 callers: z.callers,
177 importers: z.importers,
178 members: z.members,
179 dependents: hits.len(),
180 by_repo,
181 budget: PACK_BUDGET,
182 pack: g.context_pack(idx, PACK_BUDGET),
183 }
184 });
185 Explore {
186 symbols: g.node_count(),
187 edges: g.edge_count(),
188 dangling: g.dangling_edges,
189 view,
190 }
191}
192
193pub fn stats(
195 symbols: &[Symbol],
196 edges: &[Edge],
197 enriched: &HashSet<String>,
198 want: &[String],
199) -> Stats {
200 let all: BTreeSet<&str> = symbols.iter().map(|s| s.id.package.as_str()).collect();
201 let (known, unknown): (Vec<&String>, Vec<&String>) =
202 want.iter().partition(|r| all.contains(r.as_str()));
203 let repos: Vec<&str> = if want.is_empty() {
204 all.iter().copied().collect()
205 } else {
206 known.into_iter().map(String::as_str).collect()
207 };
208 let repos = repos
209 .into_iter()
210 .map(|repo| {
211 let rsyms: Vec<&Symbol> = symbols.iter().filter(|s| s.id.package == repo).collect();
212 let mut kinds = BTreeMap::new();
213 for s in &rsyms {
214 *kinds.entry(format!("{:?}", s.kind)).or_default() += 1;
215 }
216 RepoStats {
217 repo: repo.to_string(),
218 symbols: rsyms.len(),
219 edges: edges.iter().filter(|e| e.src.package == repo).count(),
220 enriched: rsyms
221 .iter()
222 .filter(|s| enriched.contains(&s.id.render()))
223 .count(),
224 kinds,
225 }
226 })
227 .collect();
228 Stats {
229 symbols: symbols.len(),
230 indexed: all.len(),
231 unknown: unknown.into_iter().cloned().collect(),
232 repos,
233 }
234}
235
236pub fn packages(deps: Vec<InternalDep>, skew: bool) -> Vec<InternalDep> {
238 deps.into_iter().filter(|d| !skew || d.skewed()).collect()
239}
240
241pub fn preflight(
243 g: &CodeGraph,
244 symbols: &[Symbol],
245 edges: &[Edge],
246 mut entries: Vec<Entry>,
247 recipes: &[Convention],
248 packages: &[crate::manifests::PackageRow],
249 changes: &[crate::preflight::Change],
250) -> Preflight {
251 let findings = crate::checks::run(&entries, symbols, edges, packages);
252 crate::checks::promote(&mut entries, &findings);
253 let markdown =
254 crate::preflight::report(g, symbols, edges, &entries, recipes, &findings, changes);
255 let changed: Vec<String> = changes
256 .iter()
257 .map(|c| format!("{}/{}", c.repo, c.path))
258 .collect();
259 let blocking = findings.iter().any(|f| {
260 f.severity == "must"
261 && f.violations
262 .iter()
263 .any(|v| changed.contains(&format!("{}/{}", v.repo, v.path)))
264 });
265 Preflight { markdown, blocking }
266}
267
268fn in_repos(repo: &str, repos: &[String]) -> bool {
269 repos.iter().any(|r| repo.eq_ignore_ascii_case(r))
270}
271
272pub fn hook(
275 symbols: &[Symbol],
276 entries: &[Entry],
277 event: &str,
278 cwd: &str,
279 path: Option<&str>,
280 seen: &HashSet<String>,
281) -> Hook {
282 use crate::ledger::{Status, Trust};
283 let (ids, indexed, limit): (Vec<String>, bool, usize) = match (event, path) {
284 ("session-start", _) => {
285 let repos: BTreeSet<&str> = symbols.iter().map(|s| s.repo.0.as_str()).collect();
286 let repo = std::path::Path::new(cwd)
287 .ancestors()
288 .filter_map(|a| a.file_name().and_then(|n| n.to_str()))
289 .find(|n| repos.contains(n));
290 let ids = symbols
291 .iter()
292 .filter(|s| Some(s.repo.0.as_str()) == repo)
293 .map(|s| s.id.render())
294 .collect();
295 (ids, repo.is_some(), 8)
296 }
297 ("pre-edit", Some(path)) => {
298 let ids = symbols
299 .iter()
300 .filter(|s| !s.file_path.is_empty() && path.ends_with(&format!("/{}", s.file_path)))
301 .map(|s| s.id.render())
302 .collect();
303 (ids, false, 4)
304 }
305 _ => return Hook::default(),
306 };
307 let fresh: Vec<&Entry> = crate::ledger::about(entries, &ids)
308 .into_iter()
309 .filter(|e| e.trust >= Trust::T2)
310 .filter(|e| e.status == Status::Active)
311 .filter(|e| !seen.contains(&e.record.id))
312 .take(limit)
313 .collect();
314 let mut text = if fresh.is_empty() {
315 String::new()
316 } else {
317 crate::ledger::markdown(
318 "comindly ledger: reasoning recorded about this code",
319 &fresh,
320 )
321 };
322 if text.len() > 9_000 {
323 let cut = (0..=9_000)
324 .rev()
325 .find(|&i| text.is_char_boundary(i))
326 .unwrap_or(0);
327 text.truncate(cut);
328 }
329 if indexed {
330 text = format!(
331 "# Using comindly\n\n{}\n{text}",
332 comindly_api::agents::GUIDE
333 );
334 }
335 Hook {
336 text,
337 ids: fresh.iter().map(|e| e.record.id.clone()).collect(),
338 }
339}