Skip to main content

comindly_engine/
mcp.rs

1//! Comindly MCP server — the code graph as tools a coding agent calls live.
2//!
3//! Loads the resolved org graph from LanceDB once at startup (local mmap or S3), holds it in
4//! memory, and serves deterministic navigation over stdio:
5//!
6//!   * `search`       — natural-language / hybrid code search (semantic + lexical + centrality)
7//!   * `repos`        — indexed repositories + symbol counts
8//!   * `suggest`      — pre-generated questions you can ask (query catalog)
9//!   * `find`         — locate symbols by name/path substring
10//!   * `zoom`         — 360° view of a symbol (callers, callees, importers, members)
11//!   * `ripple`       — org-wide blast radius (who breaks if I change this)
12//!   * `thread`       — forward call trace from an entry point
13//!   * `flow`         — pre-generated walkthrough of a flow + its live call trace
14//!   * `context_pack` — the minimal token-budgeted read-set to change a symbol safely
15//!   * `guide`        — the repo's inferred style guide
16//!
17//! Results are handed back as **markdown by default** (readable for the agent). Pass
18//! `serve --format json` for JSON text with the same JSON as structured content.
19//! Every result carries an exact `file:line`.
20
21use std::collections::HashMap;
22use std::fmt::Write as _;
23use std::sync::Arc;
24
25use crate::embed::Embedder;
26use crate::graph::{CodeGraph, Node as GNode};
27use crate::model::Symbol;
28use anyhow::Result;
29use rmcp::handler::server::wrapper::Parameters;
30use rmcp::model::{CallToolResult, ContentBlock};
31use rmcp::{schemars, tool, tool_router, ServiceExt};
32use serde::{Deserialize, Serialize};
33
34/// The org's teams, shared with the web app so changes reach agents at once.
35pub type Teams = Arc<std::sync::RwLock<crate::team::TeamSettings>>;
36
37/// Ledger records and when they were read.
38type Cached = (std::time::Instant, Vec<crate::ledger::Record>);
39
40/// Per-symbol LLM enrichment keyed by rendered symbol id: `(summary, generated_queries)`.
41type Enrichment = HashMap<String, (String, Vec<String>)>;
42
43#[derive(Clone)]
44pub struct ComindlyServer {
45    graph: Arc<CodeGraph>,
46    enrichment: Arc<Enrichment>,
47    /// Narrative style guides by repository.
48    style_guides: Arc<Vec<(String, String)>>,
49    /// Pre-generated flow walkthroughs by entry-point id: `(narration, flow_questions)`.
50    flows: Arc<HashMap<String, (String, Vec<String>)>>,
51    /// Symbols by rendered id — used by `search` to look up candidate metadata.
52    symbols: Arc<HashMap<String, Symbol>>,
53    /// Query embedder for `search`; `None` if the model could not be loaded (offline).
54    embedder: Option<Arc<Embedder>>,
55    uri: Arc<String>,
56    /// The index's tables, held open so LanceDB caches their metadata and indexes.
57    tables: Arc<crate::index::Tables>,
58    /// Ledger records with when they were read; re-read at most once a minute.
59    records: Arc<tokio::sync::Mutex<Option<Cached>>>,
60    /// Manifest/lockfile rows for internal-dependency answers.
61    packages: Arc<Vec<crate::manifests::PackageRow>>,
62    /// Measured conventions (per repo + org baseline), computed at startup from stored facts.
63    conventions: Arc<Vec<crate::conventions::Convention>>,
64    /// All edges (contracts and blast radius for `preflight`).
65    edges: Arc<Vec<crate::model::Edge>>,
66    /// Hand results back as markdown text (default). `false` → raw JSON text.
67    markdown: bool,
68    /// The key records agents write are signed with; this machine's in local mode.
69    signer: Option<std::path::PathBuf>,
70    /// Who records are written for, in local mode; on a server, the member whose token is used.
71    identity: crate::ledger::Identity,
72    /// The agent's name, when set rather than taken from the MCP handshake.
73    agent_name: Option<String>,
74    /// The org's teams: labels on repositories, and who belongs to which.
75    pub teams: Teams,
76}
77
78impl ComindlyServer {
79    /// The index root holding `_graph` and `_ledger`.
80    fn ledger_root(&self) -> String {
81        self.uri
82            .trim_end_matches('/')
83            .strip_suffix("/_graph")
84            .unwrap_or(&self.uri)
85            .to_string()
86    }
87
88    fn team_settings(&self) -> crate::team::TeamSettings {
89        self.teams.read().unwrap_or_else(|e| e.into_inner()).clone()
90    }
91
92    /// Whether `repo` is in the teams a tool's `team` argument names.
93    fn in_team(&self, team: Option<&str>, repo: &str) -> bool {
94        let sel = crate::team::parse_selection(team);
95        sel.is_empty() || self.team_settings().in_view(repo, &sel)
96    }
97
98    /// Status and trust of every record, with notes from outside a team weighed.
99    fn entries(
100        &self,
101        records: &[crate::ledger::Record],
102        symbols: &[Symbol],
103    ) -> Vec<crate::ledger::Entry> {
104        let mut e = crate::ledger::derive(records, symbols, &crate::config::local_verifier());
105        crate::ledger::weigh_teams(&mut e, records, &self.team_settings());
106        e
107    }
108
109    async fn records(&self) -> Vec<crate::ledger::Record> {
110        let mut cached = self.records.lock().await;
111        if let Some((at, r)) = cached.as_ref() {
112            if at.elapsed() < std::time::Duration::from_secs(60) {
113                return r.clone();
114            }
115        }
116        let r = match crate::ledger::Ledger::open(&self.ledger_root()) {
117            Ok(l) => l.all().await.unwrap_or_default(),
118            Err(_) => Vec::new(),
119        };
120        *cached = Some((std::time::Instant::now(), r.clone()));
121        r
122    }
123
124    /// Show a record this server just wrote without waiting for the next re-read.
125    async fn wrote(&self, rec: &crate::ledger::Record) {
126        if let Some((_, r)) = self.records.lock().await.as_mut() {
127            r.push(rec.clone());
128        }
129    }
130
131    /// The calling agent, from its MCP handshake unless named; over HTTP on a server, working for
132    /// the member whose token it uses.
133    /// Viewers' agents read; writing to the ledger needs a member.
134    fn may_write(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> anyhow::Result<()> {
135        let viewer = ctx
136            .extensions
137            .get::<axum::http::request::Parts>()
138            .and_then(|parts| parts.extensions.get::<crate::server::Who>())
139            .is_some_and(|w| !w.role.at_least(crate::server::Role::Member));
140        anyhow::ensure!(!viewer, "viewers can read, not write to the ledger");
141        Ok(())
142    }
143
144    fn agent(
145        &self,
146        ctx: &rmcp::service::RequestContext<rmcp::RoleServer>,
147    ) -> crate::ledger::Provenance {
148        let name = self
149            .agent_name
150            .clone()
151            .or_else(|| Some(ctx.peer.peer_info()?.client_info.name.clone()));
152        let who = ctx
153            .extensions
154            .get::<axum::http::request::Parts>()
155            .and_then(|parts| parts.extensions.get::<crate::server::Who>());
156        let identity = match who {
157            Some(w) => crate::ledger::Identity {
158                actor: format!("actor-{}", &blake3::hash(w.email.as_bytes()).to_hex()[..10]),
159                name: Some(w.email.clone()),
160            },
161            None => self.identity.clone(),
162        };
163        let mut p = crate::ledger::Provenance::agent("mcp", name, &identity);
164        if let Some(w) = who {
165            p.teams = self.team_settings().teams_of_member(&w.email);
166        }
167        p
168    }
169
170    /// Trusted (T2+), still-relevant ledger records about these symbols, as compact lines.
171    async fn known(&self, ids: &[String], limit: usize) -> Vec<String> {
172        let records = self.records().await;
173        if records.is_empty() {
174            return Vec::new();
175        }
176        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
177        let entries = self.entries(&records, &symbols);
178        let mut about = crate::ledger::about(&entries, ids);
179        // The owners' notes first; notes from other teams after them.
180        about.sort_by_key(|e| !e.outside.is_empty());
181        about
182            .into_iter()
183            .filter(|e| e.trust >= crate::ledger::Trust::T2)
184            .filter(|e| {
185                matches!(
186                    e.status,
187                    crate::ledger::Status::Active
188                        | crate::ledger::Status::Moved
189                        | crate::ledger::Status::Stale
190                )
191            })
192            .take(limit)
193            .map(crate::ledger::line)
194            .collect()
195    }
196
197    async fn write_record(
198        &self,
199        p: RememberParam,
200        by: crate::ledger::Provenance,
201    ) -> anyhow::Result<String> {
202        let kind = crate::ledger::Kind::parse(&p.kind)
203            .ok_or_else(|| anyhow::anyhow!("unknown kind `{}`", p.kind))?;
204        let commits = crate::index::read_repo_commits(&self.uri)
205            .await
206            .unwrap_or_default();
207        let mut anchors = Vec::new();
208        for focus in &p.on {
209            let idx = self
210                .graph
211                .lookup(focus)
212                .ok_or_else(|| anyhow::anyhow!("no symbol matches `{focus}` — use `find` first"))?;
213            let id = self.graph.node(idx).id;
214            let s = self
215                .symbols
216                .get(&id)
217                .ok_or_else(|| anyhow::anyhow!("symbol `{id}` not loaded"))?;
218            anchors.push(crate::ledger::Anchor::of(s).at(&commits));
219        }
220        let repos: Vec<&str> = anchors.iter().map(|a| a.repo.as_str()).collect();
221        let teams = self.team_settings();
222        teams.accepts(&by.teams, &repos)?;
223        let outside = teams.outside(&by.teams, &repos);
224        let commit = anchors.first().and_then(|a| a.commit.clone());
225        let mut rec = crate::ledger::Record::new(kind, &p.title, anchors, by);
226        rec.snippets = p
227            .snippets
228            .into_iter()
229            .map(|s| crate::ledger::Snippet {
230                path: s.path,
231                code: s.code,
232                commit: s.commit.or_else(|| commit.clone()),
233            })
234            .collect();
235        rec.body = p.body.unwrap_or_default();
236        rec.trigger = p.trigger;
237        rec.evidence = p.evidence;
238        rec.fields = p
239            .fields
240            .into_iter()
241            .map(|(k, v)| (k, serde_json::Value::String(v)))
242            .collect();
243        crate::ledger::Ledger::open(&self.ledger_root())?
244            .signed_by(self.signer.clone())
245            .put(&rec)
246            .await?;
247        self.wrote(&rec).await;
248        let until = match outside {
249            Some(owners) => format!("a member of {} reviews it", owners.join(" or ")),
250            None => "a human reviews it".into(),
251        };
252        Ok(format!(
253            "recorded {} `{}` (id `{}`); it is unconfirmed until {until}",
254            kind.as_str(),
255            rec.title,
256            rec.id
257        ))
258    }
259
260    async fn write_review(
261        &self,
262        p: ReviewParam,
263        by: crate::ledger::Provenance,
264    ) -> anyhow::Result<String> {
265        let ledger =
266            crate::ledger::Ledger::open(&self.ledger_root())?.signed_by(self.signer.clone());
267        let records = self.records().await;
268        anyhow::ensure!(
269            p.id.len() >= 6,
270            "give at least the first 6 characters of the record id"
271        );
272        let matches: Vec<_> = records
273            .iter()
274            .filter(|r| r.id == p.id || r.id.starts_with(&p.id))
275            .collect();
276        let target = match matches.as_slice() {
277            [one] => *one,
278            [] => anyhow::bail!("no ledger record `{}`", p.id),
279            _ => anyhow::bail!(
280                "`{}` matches {} records; give more of the id",
281                p.id,
282                matches.len()
283            ),
284        };
285        let reason = p.reason.as_deref().unwrap_or_default();
286        let rec = crate::ledger::Record::review(target, &p.action, reason, by)?;
287        ledger.put(&rec).await?;
288        self.wrote(&rec).await;
289        Ok(format!("{}: `{}`", p.action, target.title))
290    }
291
292    /// The repo a focus names: a repo itself, a file path, or a symbol.
293    fn repo_of(&self, focus: &str) -> Option<String> {
294        if self.graph.repos().iter().any(|(r, _)| r == focus) {
295            return Some(focus.to_string());
296        }
297        if let Some(s) = self
298            .symbols
299            .values()
300            .find(|s| s.kind == crate::model::SymbolKind::File && s.file_path.ends_with(focus))
301        {
302            return Some(s.repo.0.clone());
303        }
304        self.graph.lookup(focus).map(|i| self.graph.node(i).repo)
305    }
306
307    /// Build a result node, attaching its LLM summary when available.
308    fn node_dto(&self, n: GNode) -> NodeDto {
309        let enr = self.enrichment.get(&n.id);
310        NodeDto {
311            id: n.id,
312            name: n.name,
313            kind: n.kind,
314            repo: n.repo,
315            location: n.location,
316            signature: n.signature,
317            summary: enr.map(|(s, _)| s.clone()),
318            queries: enr.map(|(_, q)| q.clone()).unwrap_or_default(),
319        }
320    }
321
322    /// Wrap a tool DTO into a result: markdown text (default), or JSON text with the same JSON as
323    /// structured content.
324    fn reply<T: Serialize>(&self, dto: &T, md: String) -> CallToolResult {
325        let value = serde_json::to_value(dto).ok();
326        let text = if self.markdown {
327            md
328        } else {
329            value
330                .as_ref()
331                .and_then(|v| serde_json::to_string_pretty(v).ok())
332                .unwrap_or_default()
333        };
334        let mut r = CallToolResult::success(vec![ContentBlock::text(text)]);
335        // Clients such as Claude Code show structured content instead of the text when both come.
336        if !self.markdown {
337            r.structured_content = value;
338        }
339        r
340    }
341}
342
343// ---- DTOs (serialized to the agent) -----------------------------------------------------
344
345#[derive(Serialize, schemars::JsonSchema)]
346struct NodeDto {
347    id: String,
348    name: String,
349    kind: String,
350    repo: String,
351    /// `path:line` — jump straight here.
352    location: String,
353    signature: Option<String>,
354    /// One-line LLM summary, when the index was enriched.
355    summary: Option<String>,
356    /// Natural-language questions this symbol answers (pre-generated during `--enrich`).
357    #[serde(default, skip_serializing_if = "Vec::is_empty")]
358    queries: Vec<String>,
359}
360
361#[derive(Serialize, schemars::JsonSchema)]
362struct HopDto {
363    name: String,
364    kind: String,
365    repo: String,
366    location: String,
367    depth: usize,
368    via: String,
369}
370
371#[derive(Serialize, schemars::JsonSchema)]
372struct RepoCount {
373    repo: String,
374    symbols: usize,
375    /// Teams labelling the repository.
376    #[serde(skip_serializing_if = "Vec::is_empty")]
377    teams: Vec<String>,
378    /// Packages published by other indexed repos that this repo depends on.
379    #[serde(skip_serializing_if = "Vec::is_empty")]
380    depends_on: Vec<DepDto>,
381}
382
383#[derive(Serialize, schemars::JsonSchema)]
384struct DepDto {
385    package: String,
386    publisher: String,
387    published: Option<String>,
388    locked: Option<String>,
389    /// Pinned to a different version than the publisher's source.
390    skew: bool,
391}
392
393#[derive(Serialize, schemars::JsonSchema)]
394struct ReposDto {
395    repos: Vec<RepoCount>,
396    /// The org's teams.
397    #[serde(skip_serializing_if = "Vec::is_empty")]
398    teams: Vec<String>,
399}
400
401#[derive(Serialize, schemars::JsonSchema)]
402struct TeamCount {
403    team: String,
404    repos: usize,
405    symbols: usize,
406}
407
408#[derive(Serialize, schemars::JsonSchema)]
409struct FindDto {
410    results: Vec<NodeDto>,
411}
412
413#[derive(Serialize, schemars::JsonSchema)]
414struct ThreadDto {
415    focus: String,
416    trace: Vec<HopDto>,
417}
418
419#[derive(Serialize, schemars::JsonSchema)]
420struct FlowDto {
421    focus: String,
422    found: bool,
423    /// Pre-generated LLM walkthrough (present only if the index was built with `--flows`).
424    narration: Option<String>,
425    queries: Vec<String>,
426    trace: Vec<HopDto>,
427}
428
429#[derive(Serialize, schemars::JsonSchema)]
430struct ZoomDto {
431    found: bool,
432    focus: Option<NodeDto>,
433    container: Option<NodeDto>,
434    callers: Vec<NodeDto>,
435    callees: Vec<NodeDto>,
436    importers: Vec<NodeDto>,
437    members: Vec<NodeDto>,
438    /// Service contracts (HTTP routes, queues, buckets, tables) this symbol takes part in.
439    contracts: Vec<ContractRoleDto>,
440    /// When the focus is a contract: its providers and consumers.
441    providers: Vec<NodeDto>,
442    consumers: Vec<NodeDto>,
443    /// Recorded constraints, failed attempts and decisions about this symbol or its containers.
444    known: Vec<String>,
445}
446
447#[derive(Serialize, schemars::JsonSchema)]
448struct ContractRoleDto {
449    role: String,
450    contract: String,
451    /// Participants on the other side (consumers of what it serves, providers of what it uses).
452    counterparts: Vec<NodeDto>,
453}
454
455#[derive(Serialize, schemars::JsonSchema)]
456struct ContractDto {
457    contract: String,
458    providers: Vec<NodeDto>,
459    consumers: Vec<NodeDto>,
460}
461
462#[derive(Serialize, schemars::JsonSchema)]
463struct ContractsDto {
464    contracts: Vec<ContractDto>,
465}
466
467#[derive(Deserialize, schemars::JsonSchema, Default)]
468struct ContractsParam {
469    /// Only this contract type: `http`, `sqs`, `sns`, `s3` or `table`.
470    #[serde(default)]
471    kind: Option<String>,
472    /// Only contracts missing a provider or consumer (calls to nothing, unused routes).
473    #[serde(default)]
474    dangling: Option<bool>,
475    /// Substring filter on the contract key, e.g. `/courses` or `orders`.
476    #[serde(default)]
477    query: Option<String>,
478    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
479    #[serde(default)]
480    team: Option<String>,
481}
482
483#[derive(Serialize, schemars::JsonSchema)]
484struct RippleDto {
485    focus: String,
486    found: bool,
487    total_dependents: usize,
488    by_repo: Vec<RepoCount>,
489    /// Dependents per team; unlabelled repositories count under `none`.
490    #[serde(skip_serializing_if = "Vec::is_empty")]
491    by_team: Vec<TeamCount>,
492    dependents: Vec<HopDto>,
493}
494
495#[derive(Serialize, schemars::JsonSchema)]
496struct PackItem {
497    name: String,
498    location: String,
499    est_tokens: usize,
500    summary: Option<String>,
501}
502
503#[derive(Serialize, schemars::JsonSchema)]
504struct GuideDto {
505    found: bool,
506    /// Repo the conventions are scoped to (from `focus`), if any.
507    repo: Option<String>,
508    /// Measured conventions for the repo, then the org baseline.
509    conventions: Vec<ConventionDto>,
510    /// LLM-written narrative guide (present only if the index was built with `--guide`).
511    guide: Option<String>,
512}
513
514#[derive(Serialize, schemars::JsonSchema)]
515struct ConventionDto {
516    scope: String,
517    dimension: String,
518    rule: String,
519    /// How strictly the code follows it: `Always`, `Usually`, `Often` or `Sometimes`.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    strength: Option<&'static str>,
522    /// What it was measured from.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    evidence: Option<String>,
525    exemplars: Vec<String>,
526    counterexamples: Vec<String>,
527    /// Written or edited by a person rather than measured.
528    #[serde(skip_serializing_if = "std::ops::Not::not")]
529    team: bool,
530}
531
532#[derive(Deserialize, schemars::JsonSchema, Default)]
533struct RememberParam {
534    /// `decision` | `attempt` | `constraint` | `gotcha` | `intent` | `question`.
535    kind: String,
536    /// Symbols (ids or names) the record is about.
537    on: Vec<String>,
538    /// One neutral, factual line.
539    title: String,
540    #[serde(default)]
541    body: Option<String>,
542    /// When a future engineer would need this.
543    #[serde(default)]
544    trigger: Option<String>,
545    /// Evidence: `commit:<sha>`, `mr:!123`, `test:<path>::<name>`, error text… Required for `attempt`.
546    #[serde(default)]
547    evidence: Vec<String>,
548    /// Kind-specific fields, e.g. `{"outcome": "reverted"}`, `{"alternatives": "…"}`.
549    #[serde(default)]
550    fields: std::collections::BTreeMap<String, String>,
551    /// Up to 3 short excerpts (60 lines each) of the code that shaped this, as it is now.
552    #[serde(default)]
553    snippets: Vec<SnippetParam>,
554}
555
556#[derive(Deserialize, schemars::JsonSchema, Default)]
557struct SnippetParam {
558    /// File path, e.g. `app/auth/session.py`.
559    path: String,
560    code: String,
561    /// The commit the code is from; defaults to the indexed commit.
562    #[serde(default)]
563    commit: Option<String>,
564}
565
566#[derive(Deserialize, schemars::JsonSchema, Default)]
567struct PreflightParam {
568    /// Files you plan to change (repo-relative or absolute paths).
569    #[serde(default)]
570    files: Vec<String>,
571    /// Symbols you plan to change (ids or names).
572    #[serde(default)]
573    symbols: Vec<String>,
574}
575
576#[derive(Deserialize, schemars::JsonSchema, Default)]
577struct ReviewParam {
578    /// Record id (or a unique prefix) from `why`.
579    id: String,
580    /// `confirm` | `archive` (no longer applies) | `invalidate` (was wrong) | `supersede` | `dispute`.
581    action: String,
582    #[serde(default)]
583    reason: Option<String>,
584}
585
586#[derive(Serialize, schemars::JsonSchema)]
587struct LedgerDto {
588    ok: bool,
589    message: String,
590}
591
592#[derive(Deserialize, schemars::JsonSchema, Default)]
593struct GuideParam {
594    /// A repo name, file path or symbol: scopes conventions to its repo. Omit for org-wide.
595    #[serde(default)]
596    focus: Option<String>,
597}
598
599#[derive(Serialize, schemars::JsonSchema)]
600struct PackDto {
601    focus: String,
602    found: bool,
603    est_total_tokens: usize,
604    token_budget: usize,
605    items: Vec<PackItem>,
606    /// Recorded constraints and failed attempts about the focus and the packed symbols.
607    known: Vec<String>,
608}
609
610#[derive(Serialize, schemars::JsonSchema)]
611struct SearchHitDto {
612    /// Stable handle to pass to zoom/ripple/thread/flow/context_pack.
613    id: String,
614    name: String,
615    kind: String,
616    repo: String,
617    location: String,
618    signature: Option<String>,
619    score: f32,
620    deps: usize,
621    summary: Option<String>,
622}
623
624#[derive(Serialize, schemars::JsonSchema)]
625struct SearchDto {
626    query: String,
627    results: Vec<SearchHitDto>,
628}
629
630#[derive(Serialize, schemars::JsonSchema)]
631struct SuggestItem {
632    query: String,
633    symbol: String,
634    location: String,
635}
636
637#[derive(Serialize, schemars::JsonSchema)]
638struct SuggestDto {
639    total: usize,
640    suggestions: Vec<SuggestItem>,
641}
642
643// ---- tool parameters --------------------------------------------------------------------
644
645#[derive(Deserialize, schemars::JsonSchema, Default)]
646struct ReposParam {
647    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
648    #[serde(default)]
649    team: Option<String>,
650}
651
652#[derive(Deserialize, schemars::JsonSchema, Default)]
653struct SearchParam {
654    /// A natural-language question ("how do we create a migration?") or a symbol name.
655    query: String,
656    /// Max results (default 12).
657    limit: Option<u32>,
658    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
659    #[serde(default)]
660    team: Option<String>,
661}
662
663#[derive(Deserialize, schemars::JsonSchema, Default)]
664struct SuggestParam {
665    /// Optional keyword to filter suggestions (matches the question or the symbol name).
666    about: Option<String>,
667    /// Max suggestions (default 40).
668    limit: Option<u32>,
669    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
670    #[serde(default)]
671    team: Option<String>,
672}
673
674#[derive(Deserialize, schemars::JsonSchema, Default)]
675struct FindParam {
676    /// Symbol name or descriptor/path substring.
677    query: String,
678    /// Max results (default 20).
679    limit: Option<u32>,
680    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
681    #[serde(default)]
682    team: Option<String>,
683}
684
685#[derive(Deserialize, schemars::JsonSchema, Default)]
686struct FocusParam {
687    /// A symbol name or SCIP descriptor (e.g. `acme/const/Settings`).
688    focus: String,
689}
690
691#[derive(Deserialize, schemars::JsonSchema, Default)]
692struct DepthParam {
693    /// A symbol name or SCIP descriptor (from `find`/`search`, e.g. `acme/const/Settings`).
694    focus: String,
695    /// Max hops to traverse (default 4).
696    depth: Option<u32>,
697}
698
699#[derive(Deserialize, schemars::JsonSchema, Default)]
700struct PackParam {
701    /// A symbol name or SCIP descriptor (from `find`/`search`, e.g. `acme/const/Settings`).
702    focus: String,
703    /// Token budget for the read-set (default 1500).
704    token_budget: Option<u32>,
705}
706
707// ---- tools ------------------------------------------------------------------------------
708
709#[tool_router(vis = "pub(crate)")]
710impl ComindlyServer {
711    #[tool(
712        name = "search",
713        description = "Natural-language + lexical hybrid code search, reranked by dependency-graph centrality. Ask a question ('how do we create a migration?') or pass a symbol name."
714    )]
715    async fn search(&self, Parameters(p): Parameters<SearchParam>) -> CallToolResult {
716        let limit = p.limit.unwrap_or(12) as usize;
717        let Some(embedder) = self.embedder.as_deref() else {
718            let dto = SearchDto {
719                query: p.query.clone(),
720                results: vec![],
721            };
722            return self.reply(
723                &dto,
724                "_search unavailable: the embedding model could not be loaded (offline?). Graph tools (find/zoom/ripple/thread/context_pack) still work._".into(),
725            );
726        };
727        let hits = crate::search::hybrid(
728            &self.tables,
729            &self.symbols,
730            &self.graph,
731            &self.enrichment,
732            embedder,
733            &p.query,
734            if p.team.is_some() { limit * 5 } else { limit },
735        )
736        .await
737        .unwrap_or_default();
738        let hits: Vec<_> = hits
739            .into_iter()
740            .filter(|h| self.in_team(p.team.as_deref(), &h.repo))
741            .take(limit)
742            .collect();
743        let md = crate::search::markdown(&p.query, &hits);
744        let dto = SearchDto {
745            query: p.query.clone(),
746            results: hits.iter().map(hit_dto).collect(),
747        };
748        self.reply(&dto, md)
749    }
750
751    #[tool(
752        name = "repos",
753        description = "List indexed repositories with their symbol counts and the teams labelling each; `team` narrows to some teams"
754    )]
755    fn repos(&self, Parameters(p): Parameters<ReposParam>) -> CallToolResult {
756        let teams = self.team_settings();
757        let dto = ReposDto {
758            repos: self
759                .graph
760                .repos()
761                .into_iter()
762                .filter(|(repo, _)| self.in_team(p.team.as_deref(), repo))
763                .map(|(repo, symbols)| RepoCount {
764                    depends_on: comindly_deps(&self.packages, &repo),
765                    teams: teams.teams_of(&repo),
766                    repo,
767                    symbols,
768                })
769                .collect(),
770            teams: teams.team_list().iter().map(|t| t.name.clone()).collect(),
771        };
772        let md = md_repos(&dto);
773        self.reply(&dto, md)
774    }
775
776    #[tool(
777        name = "suggest",
778        description = "Suggested questions you can ask about this codebase (pre-generated during --enrich), the most depended-on code first; vendored code is left out. Optionally filter by a keyword to discover what flows/topics exist."
779    )]
780    fn suggest(&self, Parameters(p): Parameters<SuggestParam>) -> CallToolResult {
781        let limit = p.limit.unwrap_or(40) as usize;
782        let about = p.about.as_deref().map(str::to_lowercase);
783        let mut items: Vec<(usize, SuggestItem)> = Vec::new();
784        for (id, (_, queries)) in self.enrichment.iter() {
785            if self
786                .symbols
787                .get(id)
788                .is_some_and(|s| third_party(&s.file_path))
789            {
790                continue;
791            }
792            let used = self.graph.dependents_count(id);
793            if p.team.is_some()
794                && !self
795                    .symbols
796                    .get(id)
797                    .is_some_and(|s| self.in_team(p.team.as_deref(), &s.repo.0))
798            {
799                continue;
800            }
801            let (symbol, location) = self
802                .symbols
803                .get(id)
804                .map(|s| {
805                    (
806                        s.name.clone(),
807                        format!("{}:{}", s.file_path, s.range.start.line),
808                    )
809                })
810                .unwrap_or_else(|| (id.clone(), String::new()));
811            for q in queries {
812                if let Some(a) = &about {
813                    if !q.to_lowercase().contains(a) && !symbol.to_lowercase().contains(a) {
814                        continue;
815                    }
816                }
817                items.push((
818                    used,
819                    SuggestItem {
820                        query: q.clone(),
821                        symbol: symbol.clone(),
822                        location: location.clone(),
823                    },
824                ));
825            }
826        }
827        // The most depended-on code first: what a newcomer most needs to understand.
828        items.sort_by(|(ua, a), (ub, b)| {
829            ub.cmp(ua)
830                .then(a.symbol.cmp(&b.symbol))
831                .then(a.query.cmp(&b.query))
832        });
833        let total = items.len();
834        let items: Vec<SuggestItem> = items.into_iter().take(limit).map(|(_, i)| i).collect();
835        let dto = SuggestDto {
836            total,
837            suggestions: items,
838        };
839        let md = md_suggest(&dto);
840        self.reply(&dto, md)
841    }
842
843    #[tool(
844        name = "find",
845        description = "Find symbols by name or path substring. Returns each match with its `id` and `file:line` — the entry point to the graph tools: pass an `id` to zoom/ripple/thread/context_pack/flow."
846    )]
847    fn find(&self, Parameters(p): Parameters<FindParam>) -> CallToolResult {
848        let limit = p.limit.unwrap_or(20) as usize;
849        let dto = FindDto {
850            results: self
851                .graph
852                .find(&p.query, if p.team.is_some() { limit * 5 } else { limit })
853                .into_iter()
854                .filter(|n| self.in_team(p.team.as_deref(), &n.repo))
855                .take(limit)
856                .map(|n| self.node_dto(n))
857                .collect(),
858        };
859        let md = md_find(&p.query, &dto);
860        self.reply(&dto, md)
861    }
862
863    #[tool(
864        name = "zoom",
865        description = "360° view of one symbol — definition, container, callers, callees, importers, members. Use to understand a symbol before changing it. Takes a symbol id/name from find/search."
866    )]
867    async fn zoom(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
868        let dto = match self.graph.lookup(&p.focus) {
869            None => ZoomDto {
870                found: false,
871                focus: None,
872                container: None,
873                callers: vec![],
874                callees: vec![],
875                importers: vec![],
876                members: vec![],
877                contracts: vec![],
878                providers: vec![],
879                consumers: vec![],
880                known: vec![],
881            },
882            Some(idx) => {
883                let z = self.graph.zoom(idx);
884                ZoomDto {
885                    found: true,
886                    focus: z.focus.map(|n| self.node_dto(n)),
887                    container: z.container.map(|n| self.node_dto(n)),
888                    callers: z.callers.into_iter().map(|n| self.node_dto(n)).collect(),
889                    callees: z.callees.into_iter().map(|n| self.node_dto(n)).collect(),
890                    importers: z.importers.into_iter().map(|n| self.node_dto(n)).collect(),
891                    members: z.members.into_iter().map(|n| self.node_dto(n)).collect(),
892                    contracts: z
893                        .contracts
894                        .into_iter()
895                        .map(|c| ContractRoleDto {
896                            role: c.role.to_string(),
897                            contract: c.contract.name,
898                            counterparts: c
899                                .counterparts
900                                .into_iter()
901                                .map(|n| self.node_dto(n))
902                                .collect(),
903                        })
904                        .collect(),
905                    providers: z.providers.into_iter().map(|n| self.node_dto(n)).collect(),
906                    consumers: z.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
907                    known: match self.graph.scope_of(&p.focus) {
908                        Some((_, ids)) => self.known(&ids, 5).await,
909                        None => vec![],
910                    },
911                }
912            }
913        };
914        let md = md_zoom(&p.focus, &dto);
915        self.reply(&dto, md)
916    }
917
918    #[tool(
919        name = "contracts",
920        description = "Service contracts across repos: HTTP routes ↔ clients, SQS/SNS queues and topics, S3 buckets, DB tables — each with providers and consumers. Use to see how services talk, find calls to routes nobody serves (`dangling`), or filter by `kind`/`query`. `ripple` already crosses these."
921    )]
922    fn contracts(&self, Parameters(p): Parameters<ContractsParam>) -> CallToolResult {
923        let views = self
924            .graph
925            .contracts(p.kind.as_deref(), p.dangling.unwrap_or(false));
926        let dto = ContractsDto {
927            contracts: views
928                .into_iter()
929                .filter(|v| {
930                    p.query
931                        .as_deref()
932                        .is_none_or(|q| v.contract.name.contains(q))
933                })
934                .filter(|v| {
935                    p.team.is_none()
936                        || v.providers
937                            .iter()
938                            .chain(&v.consumers)
939                            .any(|n| self.in_team(p.team.as_deref(), &n.repo))
940                })
941                .take(200)
942                .map(|v| ContractDto {
943                    contract: v.contract.name,
944                    providers: v.providers.into_iter().map(|n| self.node_dto(n)).collect(),
945                    consumers: v.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
946                })
947                .collect(),
948        };
949        let md = md_contracts(&dto);
950        self.reply(&dto, md)
951    }
952
953    #[tool(
954        name = "why",
955        description = "Reasoning blame for a symbol: recorded decisions, failed attempts, constraints, gotchas and intents about it or its class/file — with status (active / stale since the code changed / orphaned), trust and evidence. Check before changing code so you don't repeat a failed approach or break a constraint."
956    )]
957    async fn why(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
958        let Some((name, ids)) = self.graph.scope_of(&p.focus) else {
959            let dto = LedgerDto {
960                ok: false,
961                message: format!("no symbol matches `{}`", p.focus),
962            };
963            let md = format!("_{}_", dto.message);
964            return self.reply(&dto, md);
965        };
966        let records = self.records().await;
967        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
968        let entries = self.entries(&records, &symbols);
969        let about = crate::ledger::about(&entries, &ids);
970        let md = crate::ledger::markdown(&format!("why `{name}`"), &about);
971        let dto = LedgerDto {
972            ok: true,
973            message: md.clone(),
974        };
975        self.reply(&dto, md)
976    }
977
978    #[tool(
979        name = "preflight",
980        description = "Before you change code: given the files and/or symbols you plan to edit, returns recorded constraints and failed attempts about them, contracts (routes, tables, queues) other services consume, blast radius across repos, and directories that usually change together but aren't in your plan. Run it on your plan before editing."
981    )]
982    async fn preflight(&self, Parameters(p): Parameters<PreflightParam>) -> CallToolResult {
983        let mut changes = Vec::new();
984        for f in &p.files {
985            if let Some(s) = self.symbols.values().find(|s| {
986                s.kind == crate::model::SymbolKind::File
987                    && (s.file_path == *f || f.ends_with(&format!("/{}", s.file_path)))
988            }) {
989                changes.push(crate::preflight::Change {
990                    repo: s.repo.0.clone(),
991                    path: s.file_path.clone(),
992                    lines: Vec::new(),
993                });
994            }
995        }
996        for focus in &p.symbols {
997            let Some(idx) = self.graph.lookup(focus) else {
998                continue;
999            };
1000            if let Some(s) = self.symbols.get(&self.graph.node(idx).id) {
1001                changes.push(crate::preflight::Change {
1002                    repo: s.repo.0.clone(),
1003                    path: s.file_path.clone(),
1004                    lines: vec![(s.range.start.line, s.range.end.line)],
1005                });
1006            }
1007        }
1008        let records = self.records().await;
1009        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
1010        let entries = self.entries(&records, &symbols);
1011        let findings = crate::checks::run(&entries, &symbols, &self.edges, &self.packages);
1012        let recipes: Vec<crate::conventions::Convention> = self
1013            .conventions
1014            .iter()
1015            .filter(|c| c.dimension == "recipes")
1016            .cloned()
1017            .collect();
1018        let md = if changes.is_empty() {
1019            "_no indexed files or symbols matched — pass repo-relative paths or names from `find`._"
1020                .to_string()
1021        } else {
1022            crate::preflight::report(
1023                &self.graph,
1024                &symbols,
1025                &self.edges,
1026                &entries,
1027                &recipes,
1028                &findings,
1029                &changes,
1030            ) + &crate::preflight::teams_affected(
1031                &self.graph,
1032                &symbols,
1033                &changes,
1034                &self.team_settings(),
1035            )
1036        };
1037        let dto = LedgerDto {
1038            ok: !changes.is_empty(),
1039            message: md.clone(),
1040        };
1041        self.reply(&dto, md)
1042    }
1043
1044    #[tool(
1045        name = "remember",
1046        description = "Record durable engineering knowledge about symbols for the team: a failed or reverted approach (`attempt`, evidence required), a choice between alternatives (`decision`), an invariant (`constraint`), a trap (`gotcha`), why code exists (`intent`) or an open `question`. Only record what a future engineer would be glad to know before editing — not summaries of what you did. Write neutral facts, never instructions."
1047    )]
1048    async fn remember(
1049        &self,
1050        ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1051        Parameters(p): Parameters<RememberParam>,
1052    ) -> CallToolResult {
1053        let result = match Self::may_write(&ctx) {
1054            Ok(()) => self.write_record(p, self.agent(&ctx)).await,
1055            Err(e) => Err(e),
1056        };
1057        let dto = LedgerDto {
1058            ok: result.is_ok(),
1059            message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1060        };
1061        let md = dto.message.clone();
1062        self.reply(&dto, md)
1063    }
1064
1065    #[tool(
1066        name = "review",
1067        description = "Act on a ledger record after checking it against the code: `confirm` (still true), `archive` (was true, no longer applies), `invalidate` (was wrong), `supersede` (replaced by a newer record) or `dispute` (contradicted). Give a reason. Retiring a record a person wrote or confirmed only flags it for a person to decide. Use when `why` shows a STALE record you have verified."
1068    )]
1069    async fn review(
1070        &self,
1071        ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1072        Parameters(p): Parameters<ReviewParam>,
1073    ) -> CallToolResult {
1074        let result = match Self::may_write(&ctx) {
1075            Ok(()) => self.write_review(p, self.agent(&ctx)).await,
1076            Err(e) => Err(e),
1077        };
1078        let dto = LedgerDto {
1079            ok: result.is_ok(),
1080            message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1081        };
1082        let md = dto.message.clone();
1083        self.reply(&dto, md)
1084    }
1085
1086    #[tool(
1087        name = "ripple",
1088        description = "Blast radius (reverse deps): who transitively depends on this symbol, cross-repo, grouped by repo and by team. Use to gauge the impact of changing it. This is the reverse direction of `thread`. Returns dependents with depth + repo counts."
1089    )]
1090    fn ripple(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1091        let depth = p.depth.unwrap_or(4) as usize;
1092        let dto = match self.graph.lookup(&p.focus) {
1093            None => RippleDto {
1094                focus: p.focus.clone(),
1095                found: false,
1096                total_dependents: 0,
1097                by_repo: vec![],
1098                by_team: vec![],
1099                dependents: vec![],
1100            },
1101            Some(idx) => {
1102                let hops = self.graph.ripple(idx, depth);
1103                let teams = self.team_settings();
1104                let mut by_repo: std::collections::BTreeMap<String, usize> =
1105                    std::collections::BTreeMap::new();
1106                for h in &hops {
1107                    *by_repo.entry(h.node.repo.clone()).or_default() += 1;
1108                }
1109                RippleDto {
1110                    focus: p.focus.clone(),
1111                    found: true,
1112                    total_dependents: hops.len(),
1113                    by_team: by_team(&teams, &by_repo),
1114                    by_repo: by_repo
1115                        .into_iter()
1116                        .map(|(repo, symbols)| RepoCount {
1117                            teams: teams.teams_of(&repo),
1118                            repo,
1119                            symbols,
1120                            depends_on: Vec::new(),
1121                        })
1122                        .collect(),
1123                    dependents: hops.into_iter().map(hop_dto).collect(),
1124                }
1125            }
1126        };
1127        let md = md_ripple(&dto);
1128        self.reply(&dto, md)
1129    }
1130
1131    #[tool(
1132        name = "guide",
1133        description = "Measured coding conventions to follow when editing: naming, signatures, base classes, error roots, layout, logging, config, internal libraries to use instead of raw ones, testing — each with counts, exemplar locations and known deviations. Pass `focus` (repo, file or symbol) for its repo's conventions and narrative guide; the org baseline is always included. Without `focus`: the org baseline and which repos have a guide."
1134    )]
1135    fn guide(&self, Parameters(p): Parameters<GuideParam>) -> CallToolResult {
1136        let repo = p.focus.as_deref().and_then(|f| self.repo_of(f));
1137        let repo_keys: std::collections::HashSet<&str> = self
1138            .conventions
1139            .iter()
1140            .filter(|c| Some(&c.repo) == repo.as_ref())
1141            .map(|c| c.key.as_str())
1142            .collect();
1143        let mut conventions: Vec<ConventionDto> = Vec::new();
1144        for c in self.conventions.iter() {
1145            let scoped = Some(&c.repo) == repo.as_ref();
1146            let org = c.repo == crate::conventions::ORG && !repo_keys.contains(c.key.as_str());
1147            if scoped || org {
1148                conventions.push(ConventionDto {
1149                    scope: c.repo.clone(),
1150                    dimension: c.dimension.clone(),
1151                    rule: c.rule.clone(),
1152                    strength: c.strength(),
1153                    evidence: c.evidence(),
1154                    exemplars: c.exemplars.iter().take(2).cloned().collect(),
1155                    counterexamples: c.counterexamples.iter().take(2).cloned().collect(),
1156                    team: c.origin != crate::conventions::Origin::Measured,
1157                });
1158            }
1159        }
1160        // One repository's narrative guide; all of them together outgrow what a client shows.
1161        let guide = match &repo {
1162            Some(r) => self
1163                .style_guides
1164                .iter()
1165                .find(|(g, _)| g == r)
1166                .map(|(_, g)| g.clone()),
1167            None => (!self.style_guides.is_empty()).then(|| {
1168                let names: Vec<&str> = self.style_guides.iter().map(|(r, _)| r.as_str()).collect();
1169                format!(
1170                    "Written for each repository; pass `focus` to read one: {}.",
1171                    names.join(", ")
1172                )
1173            }),
1174        };
1175        let dto = GuideDto {
1176            found: !conventions.is_empty() || guide.is_some(),
1177            repo,
1178            conventions,
1179            guide,
1180        };
1181        let md = md_guide(&dto);
1182        self.reply(&dto, md)
1183    }
1184
1185    #[tool(
1186        name = "thread",
1187        description = "Forward call trace from an entry point: what this symbol calls, transitively, with depth + edge kind. Use to follow execution downstream. `flow` = this trace plus a narrated explanation; `ripple` = the reverse (who calls in)."
1188    )]
1189    fn thread(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1190        let depth = p.depth.unwrap_or(4) as usize;
1191        let trace = match self.graph.lookup(&p.focus) {
1192            Some(idx) => self
1193                .graph
1194                .thread(idx, depth)
1195                .into_iter()
1196                .map(hop_dto)
1197                .collect(),
1198            None => vec![],
1199        };
1200        let dto = ThreadDto {
1201            focus: p.focus.clone(),
1202            trace,
1203        };
1204        let md = md_thread(&dto);
1205        self.reply(&dto, md)
1206    }
1207
1208    #[tool(
1209        name = "flow",
1210        description = "Explain how a flow works: a pre-generated LLM walkthrough of an entry point (when the index was built with --flows) plus its live forward call trace."
1211    )]
1212    fn flow(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1213        let depth = p.depth.unwrap_or(4) as usize;
1214        let dto = match self.graph.lookup(&p.focus) {
1215            None => FlowDto {
1216                focus: p.focus.clone(),
1217                found: false,
1218                narration: None,
1219                queries: vec![],
1220                trace: vec![],
1221            },
1222            Some(idx) => {
1223                let fid = self.graph.zoom(idx).focus.map(|n| n.id);
1224                let (narration, queries) = fid
1225                    .as_ref()
1226                    .and_then(|id| self.flows.get(id))
1227                    .map(|(n, q)| (Some(n.clone()), q.clone()))
1228                    .unwrap_or((None, vec![]));
1229                let trace = self
1230                    .graph
1231                    .thread(idx, depth)
1232                    .into_iter()
1233                    .map(hop_dto)
1234                    .collect();
1235                FlowDto {
1236                    focus: p.focus.clone(),
1237                    found: true,
1238                    narration,
1239                    queries,
1240                    trace,
1241                }
1242            }
1243        };
1244        let md = md_flow(&dto);
1245        self.reply(&dto, md)
1246    }
1247
1248    #[tool(
1249        name = "context_pack",
1250        description = "The minimal token-budgeted set of symbols to read before editing the focus symbol safely. Use to assemble just-enough context for a change. Returns ranked symbols with locations + token estimates within the budget."
1251    )]
1252    async fn context_pack(&self, Parameters(p): Parameters<PackParam>) -> CallToolResult {
1253        let budget = p.token_budget.unwrap_or(1500) as usize;
1254        let dto = match self.graph.lookup(&p.focus) {
1255            None => PackDto {
1256                focus: p.focus.clone(),
1257                found: false,
1258                est_total_tokens: 0,
1259                token_budget: budget,
1260                items: vec![],
1261                known: vec![],
1262            },
1263            Some(idx) => {
1264                let pack = self.graph.context_pack(idx, budget);
1265                let est_total_tokens = pack.iter().map(|(_, t)| t).sum();
1266                let mut ids: Vec<String> = pack.iter().map(|(n, _)| n.id.clone()).collect();
1267                if let Some((_, scope)) = self.graph.scope_of(&p.focus) {
1268                    ids.extend(scope);
1269                }
1270                PackDto {
1271                    focus: p.focus.clone(),
1272                    found: true,
1273                    est_total_tokens,
1274                    token_budget: budget,
1275                    known: self.known(&ids, 6).await,
1276                    items: pack
1277                        .into_iter()
1278                        .map(|(n, t)| PackItem {
1279                            summary: self.enrichment.get(&n.id).map(|(s, _)| s.clone()),
1280                            name: n.name,
1281                            location: n.location,
1282                            est_tokens: t,
1283                        })
1284                        .collect(),
1285                }
1286            }
1287        };
1288        let md = md_pack(&dto);
1289        self.reply(&dto, md)
1290    }
1291}
1292
1293impl ComindlyServer {
1294    /// The markdown a read-only tool gives an agent for these arguments, for the app's agent view.
1295    pub async fn as_agent(&self, tool: &str, args: serde_json::Value) -> Result<String> {
1296        let me = Self {
1297            markdown: true,
1298            ..self.clone()
1299        };
1300        macro_rules! args {
1301            () => {
1302                Parameters(serde_json::from_value(args)?)
1303            };
1304        }
1305        let r = match tool {
1306            "repos" => me.repos(args!()),
1307            "contracts" => me.contracts(args!()),
1308            "guide" => me.guide(args!()),
1309            "find" => me.find(args!()),
1310            "suggest" => me.suggest(args!()),
1311            "ripple" => me.ripple(args!()),
1312            "thread" => me.thread(args!()),
1313            "flow" => me.flow(args!()),
1314            "zoom" => me.zoom(args!()).await,
1315            "why" => me.why(args!()).await,
1316            "context_pack" => me.context_pack(args!()).await,
1317            _ => anyhow::bail!("`{tool}` is not a read-only tool"),
1318        };
1319        Ok(r.content
1320            .iter()
1321            .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
1322            .collect::<Vec<_>>()
1323            .join("\n"))
1324    }
1325}
1326
1327#[rmcp::tool_handler(router = Self::tool_router())]
1328impl rmcp::ServerHandler for ComindlyServer {
1329    fn get_info(&self) -> rmcp::model::ServerConfig {
1330        rmcp::model::ServerConfig::new(
1331            rmcp::model::ServerCapabilities::builder()
1332                .enable_tools()
1333                .build(),
1334        )
1335        .with_server_info(rmcp::model::Implementation::from_build_env())
1336        .with_instructions(crate::agents::GUIDE)
1337    }
1338
1339    /// Each tool call, logged with the client's own name, so a review bot shows apart from an editor.
1340    async fn call_tool(
1341        &self,
1342        request: rmcp::model::CallToolRequestParams,
1343        context: rmcp::service::RequestContext<rmcp::RoleServer>,
1344    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1345        let tool = request.name.to_string();
1346        let parts = context.extensions.get::<axum::http::request::Parts>();
1347        // Through the gateway, its name for the OAuth client; otherwise the client's own, unless the
1348        // MCP library filled in its own name for a request without a session.
1349        let client = parts
1350            .and_then(|p| p.extensions.get::<crate::server::GatewayClient>())
1351            .map(|c| c.0.clone())
1352            .or_else(|| {
1353                let own = rmcp::model::Implementation::from_build_env().name;
1354                context
1355                    .peer
1356                    .peer_info()
1357                    .map(|p| p.client_info.name.clone())
1358                    .filter(|n| *n != own)
1359            })
1360            .unwrap_or_default();
1361        let email = parts
1362            .and_then(|p| p.extensions.get::<crate::server::Who>())
1363            .map(|w| w.email.clone());
1364        let start = std::time::Instant::now();
1365        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
1366        let out = Self::tool_router().call(tcc).await;
1367        let ms = start.elapsed().as_millis() as u64;
1368        let ok = out.is_ok();
1369        if !crate::telemetry::logs("tool") {
1370            return out;
1371        }
1372        tracing::info!(
1373            kind = "tool",
1374            tool = %tool,
1375            client = %client,
1376            email = email.as_deref(),
1377            ok,
1378            duration_ms = ms,
1379            "tool {tool} by {} {ms} ms",
1380            if client.is_empty() { "an unnamed client" } else { client.as_str() }
1381        );
1382        out
1383    }
1384}
1385
1386fn hop_dto(h: crate::graph::Hop) -> HopDto {
1387    HopDto {
1388        name: h.node.name,
1389        kind: h.node.kind,
1390        repo: h.node.repo,
1391        location: h.node.location,
1392        depth: h.depth,
1393        via: h.via.as_str().to_string(),
1394    }
1395}
1396
1397fn hit_dto(h: &crate::search::SearchHit) -> SearchHitDto {
1398    SearchHitDto {
1399        id: h.id.clone(),
1400        name: h.name.clone(),
1401        kind: h.kind.clone(),
1402        repo: h.repo.clone(),
1403        location: h.location.clone(),
1404        signature: h.signature.clone(),
1405        score: h.score,
1406        deps: h.deps,
1407        summary: h.summary.clone(),
1408    }
1409}
1410
1411/// Code vendored from elsewhere, which suggestions skip.
1412fn third_party(path: &str) -> bool {
1413    path.split('/').any(|c| {
1414        matches!(
1415            c,
1416            "_vendor" | "vendor" | "vendored" | "third_party" | "node_modules" | "site-packages"
1417        )
1418    })
1419}
1420
1421// ---- markdown rendering (the agent-facing handover) -------------------------------------
1422
1423fn node_bullet(o: &mut String, n: &NodeDto) {
1424    let _ = write!(o, "- **{}** _{}_ — `{}`", n.name, n.kind, n.location);
1425    if !n.repo.is_empty() {
1426        let _ = write!(o, " · {}", n.repo);
1427    }
1428    if let Some(s) = &n.summary {
1429        let _ = write!(o, " — {s}");
1430    }
1431    // Stable handle to pass back to zoom/ripple/thread/flow/context_pack.
1432    let _ = write!(o, "  ·  id `{}`", n.id);
1433    o.push('\n');
1434}
1435
1436fn node_section(o: &mut String, title: &str, nodes: &[NodeDto]) {
1437    if nodes.is_empty() {
1438        return;
1439    }
1440    let _ = write!(o, "\n**{title}** ({})\n", nodes.len());
1441    for n in nodes {
1442        node_bullet(o, n);
1443    }
1444}
1445
1446fn md_repos(d: &ReposDto) -> String {
1447    let mut o = String::from("## Indexed repositories\n\n");
1448    if !d.teams.is_empty() {
1449        let _ = writeln!(o, "Teams: {}\n", d.teams.join(", "));
1450    }
1451    if d.repos.is_empty() {
1452        return o + "_none_\n";
1453    }
1454    for r in &d.repos {
1455        let teams = if r.teams.is_empty() {
1456            String::new()
1457        } else {
1458            format!(" · teams: {}", r.teams.join(", "))
1459        };
1460        let _ = writeln!(o, "- **{}** — {} symbols{teams}", r.repo, r.symbols);
1461        for dep in &r.depends_on {
1462            let _ = writeln!(
1463                o,
1464                "  - uses `{}` from **{}** — locked {} vs published {}{}",
1465                dep.package,
1466                dep.publisher,
1467                dep.locked.as_deref().unwrap_or("?"),
1468                dep.published.as_deref().unwrap_or("?"),
1469                if dep.skew { " ⚠ skew" } else { "" }
1470            );
1471        }
1472    }
1473    o
1474}
1475
1476fn comindly_deps(rows: &[crate::manifests::PackageRow], repo: &str) -> Vec<DepDto> {
1477    crate::manifests::internal(rows)
1478        .into_iter()
1479        .filter(|d| d.consumer == repo)
1480        .map(|d| DepDto {
1481            skew: d.skewed(),
1482            package: d.name,
1483            publisher: d.publisher,
1484            published: d.published,
1485            locked: d.locked,
1486        })
1487        .collect()
1488}
1489
1490fn md_suggest(d: &SuggestDto) -> String {
1491    let shown = d.suggestions.len();
1492    let mut o = if d.total > shown {
1493        format!(
1494            "## Suggested questions ({shown} of {} — pass `about` to filter)\n\n",
1495            d.total
1496        )
1497    } else {
1498        format!("## Suggested questions ({shown})\n\n")
1499    };
1500    if d.suggestions.is_empty() {
1501        return o + "_none — index was built without `--enrich`, or no match_\n";
1502    }
1503    let mut cur = "";
1504    for it in &d.suggestions {
1505        if it.symbol != cur {
1506            let _ = write!(o, "\n**{}** `{}`\n", it.symbol, it.location);
1507            cur = &it.symbol;
1508        }
1509        let _ = writeln!(o, "- {}", it.query);
1510    }
1511    o
1512}
1513
1514fn md_find(query: &str, d: &FindDto) -> String {
1515    let mut o = format!("## `find` \"{query}\" — {} result(s)\n\n", d.results.len());
1516    if d.results.is_empty() {
1517        return o + "_no symbols matched_\n";
1518    }
1519    for n in &d.results {
1520        node_bullet(&mut o, n);
1521    }
1522    o
1523}
1524
1525fn md_zoom(focus: &str, d: &ZoomDto) -> String {
1526    if !d.found {
1527        return format!("## zoom `{focus}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n");
1528    }
1529    let mut o = String::new();
1530    if let Some(f) = &d.focus {
1531        let _ = writeln!(o, "## {} _{}_\n`{}`", f.name, f.kind, f.location);
1532        if let Some(sig) = &f.signature {
1533            let _ = writeln!(o, "\n```\n{sig}\n```");
1534        }
1535        if let Some(s) = &f.summary {
1536            let _ = writeln!(o, "\n{s}");
1537        }
1538        if !f.queries.is_empty() {
1539            let _ = writeln!(o, "\n**Ask:** {}", f.queries.join(" · "));
1540        }
1541    } else {
1542        let _ = writeln!(o, "## zoom `{focus}`");
1543    }
1544    if let Some(c) = &d.container {
1545        let _ = write!(o, "\n**Container**\n");
1546        node_bullet(&mut o, c);
1547    }
1548    node_section(&mut o, "Members", &d.members);
1549    node_section(&mut o, "Callers", &d.callers);
1550    node_section(&mut o, "Callees", &d.callees);
1551    node_section(&mut o, "Importers", &d.importers);
1552    if !d.known.is_empty() {
1553        o.push_str(
1554            "\n**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1555        );
1556        for k in &d.known {
1557            let _ = writeln!(o, "- {k}");
1558        }
1559    }
1560    node_section(&mut o, "Provided by", &d.providers);
1561    node_section(&mut o, "Used by", &d.consumers);
1562    if !d.contracts.is_empty() {
1563        let _ = write!(o, "\n**Contracts**\n");
1564        for c in &d.contracts {
1565            let _ = writeln!(o, "- {} `{}`", c.role, c.contract);
1566            for n in &c.counterparts {
1567                let _ = writeln!(o, "  - **{}** ({}) `{}`", n.name, n.repo, n.location);
1568            }
1569        }
1570    }
1571    o
1572}
1573
1574fn md_contracts(d: &ContractsDto) -> String {
1575    let mut o = format!("## {} contract(s)\n", d.contracts.len());
1576    for c in &d.contracts {
1577        let _ = writeln!(o, "\n### `{}`", c.contract);
1578        if c.providers.is_empty() {
1579            o.push_str("- provided by: _none indexed_\n");
1580        }
1581        // A symbol on both sides is listed once.
1582        let both = |n: &NodeDto| c.consumers.iter().any(|m| m.id == n.id);
1583        let sides = c.providers.iter().map(|n| {
1584            (
1585                n,
1586                if both(n) {
1587                    "provides and uses"
1588                } else {
1589                    "provided by"
1590                },
1591            )
1592        });
1593        let users = c
1594            .consumers
1595            .iter()
1596            .filter(|n| !c.providers.iter().any(|m| m.id == n.id));
1597        for (n, label) in sides.chain(users.map(|n| (n, "used by"))) {
1598            let _ = writeln!(
1599                o,
1600                "- {label}: **{}** ({}) `{}` · id `{}`",
1601                n.name, n.repo, n.location, n.id
1602            );
1603        }
1604        if c.consumers.is_empty() {
1605            o.push_str("- used by: _none indexed_\n");
1606        }
1607    }
1608    o
1609}
1610
1611fn md_hop_lines(o: &mut String, hops: &[HopDto]) {
1612    for h in hops {
1613        let _ = writeln!(
1614            o,
1615            "- _d{}_ via **{}** — **{}** `{}`",
1616            h.depth, h.via, h.name, h.location
1617        );
1618    }
1619}
1620
1621fn md_ripple(d: &RippleDto) -> String {
1622    if !d.found {
1623        return format!("## Blast radius: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1624    }
1625    let mut o = format!(
1626        "## Blast radius: `{}`\n\n**{} dependent(s)**",
1627        d.focus, d.total_dependents
1628    );
1629    if !d.by_repo.is_empty() {
1630        let by: Vec<String> = d
1631            .by_repo
1632            .iter()
1633            .map(|r| format!("{} ({})", r.repo, r.symbols))
1634            .collect();
1635        let _ = write!(o, " across {}", by.join(", "));
1636    }
1637    if !d.by_team.is_empty() {
1638        let by: Vec<String> = d
1639            .by_team
1640            .iter()
1641            .map(|t| {
1642                let what = if t.repos == 1 { "repo" } else { "repos" };
1643                format!("{} ({} {what}, {} symbols)", t.team, t.repos, t.symbols)
1644            })
1645            .collect();
1646        let _ = write!(o, "\n\nTeams affected: {}", by.join(", "));
1647    }
1648    o.push_str("\n\n");
1649    md_hop_lines(&mut o, &d.dependents);
1650    o
1651}
1652
1653/// Dependents per team from per-repo counts; a repository in two teams counts for both.
1654fn by_team(
1655    teams: &crate::team::TeamSettings,
1656    by_repo: &std::collections::BTreeMap<String, usize>,
1657) -> Vec<TeamCount> {
1658    if teams.team_list().is_empty() {
1659        return Vec::new();
1660    }
1661    let mut out: Vec<TeamCount> = Vec::new();
1662    for (repo, n) in by_repo {
1663        let mut names = teams.teams_of(repo);
1664        if names.is_empty() {
1665            names.push(crate::team::UNASSIGNED.to_string());
1666        }
1667        for name in names {
1668            match out.iter_mut().find(|t| t.team == name) {
1669                Some(t) => {
1670                    t.repos += 1;
1671                    t.symbols += n;
1672                }
1673                None => out.push(TeamCount {
1674                    team: name,
1675                    repos: 1,
1676                    symbols: *n,
1677                }),
1678            }
1679        }
1680    }
1681    out.sort_by(|a, b| b.symbols.cmp(&a.symbols).then(a.team.cmp(&b.team)));
1682    out
1683}
1684
1685fn md_thread(d: &ThreadDto) -> String {
1686    let mut o = format!(
1687        "## Call trace from `{}` — {} step(s)\n\n",
1688        d.focus,
1689        d.trace.len()
1690    );
1691    if d.trace.is_empty() {
1692        return o + "_no outgoing calls found_\n";
1693    }
1694    md_hop_lines(&mut o, &d.trace);
1695    o
1696}
1697
1698fn md_flow(d: &FlowDto) -> String {
1699    if !d.found {
1700        return format!("## Flow: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1701    }
1702    let mut o = format!("## Flow: `{}`\n\n", d.focus);
1703    match &d.narration {
1704        Some(n) => {
1705            let _ = writeln!(o, "{n}\n");
1706        }
1707        None => {
1708            let _ = writeln!(
1709                o,
1710                "_no pre-generated narration (build the index with `--flows` to add one) — raw call trace below._\n"
1711            );
1712        }
1713    }
1714    if !d.queries.is_empty() {
1715        let _ = writeln!(o, "**Ask:** {}\n", d.queries.join(" · "));
1716    }
1717    let _ = writeln!(o, "**Call trace** ({} steps)", d.trace.len());
1718    md_hop_lines(&mut o, &d.trace);
1719    o
1720}
1721
1722fn md_pack(d: &PackDto) -> String {
1723    if !d.found {
1724        return format!("## Context pack: `{}`\n\n_no symbol matched — call `find` with the name to get its id, then retry._\n", d.focus);
1725    }
1726    let mut o = format!(
1727        "## Context to change `{}`\n\n_~{} of {} token budget · {} symbols_\n\n",
1728        d.focus,
1729        d.est_total_tokens,
1730        d.token_budget,
1731        d.items.len()
1732    );
1733    if !d.known.is_empty() {
1734        o.push_str(
1735            "**Known constraints & dead ends** (team ledger — verify before relying on them)\n",
1736        );
1737        for k in &d.known {
1738            let _ = writeln!(o, "- {k}");
1739        }
1740        o.push('\n');
1741    }
1742    for (i, it) in d.items.iter().enumerate() {
1743        let _ = write!(
1744            o,
1745            "{}. **{}** — `{}` _(~{} tok)_",
1746            i + 1,
1747            it.name,
1748            it.location,
1749            it.est_tokens
1750        );
1751        if let Some(s) = &it.summary {
1752            let _ = write!(o, " — {s}");
1753        }
1754        o.push('\n');
1755    }
1756    o
1757}
1758
1759fn md_guide(d: &GuideDto) -> String {
1760    let mut o = match &d.repo {
1761        Some(r) => format!("## Conventions — {r}\n"),
1762        None => "## Conventions — org baseline\n".to_string(),
1763    };
1764    if d.conventions.is_empty() {
1765        o.push_str("\n_none measured — re-run `comindly index`/`link` to store facts._\n");
1766    }
1767    let mut org_header = false;
1768    for c in &d.conventions {
1769        if c.scope == crate::conventions::ORG && d.repo.is_some() && !org_header {
1770            o.push_str("\n**Org baseline**\n");
1771            org_header = true;
1772        }
1773        let team = if c.team { " _(set by the team)_" } else { "" };
1774        let how = c
1775            .strength
1776            .map(|s| format!(" ({})", s.to_lowercase()))
1777            .unwrap_or_default();
1778        let _ = writeln!(o, "- _{}_{how}: {}{team}", c.dimension, c.rule);
1779        if !c.exemplars.is_empty() {
1780            let _ = writeln!(o, "  - e.g. `{}`", c.exemplars.join("`, `"));
1781        }
1782        if !c.counterexamples.is_empty() {
1783            let _ = writeln!(o, "  - deviations: `{}`", c.counterexamples.join("`, `"));
1784        }
1785    }
1786    if let Some(g) = &d.guide {
1787        let _ = write!(o, "\n## Narrative guide\n\n{g}\n");
1788    }
1789    o
1790}
1791
1792/// Load the graph (and any LLM enrichment) from `uri` and serve MCP over stdio until the
1793/// client disconnects. `markdown` controls whether tool results are handed back as markdown
1794/// text (default) or raw JSON.
1795pub async fn serve_stdio(uri: &str, markdown: bool) -> Result<()> {
1796    let mut server = load(uri, markdown, crate::config::Config::load().fresh()).await?;
1797    server.signer = crate::config::local_signing_key();
1798    server.identity = crate::config::local_identity();
1799    server.agent_name = crate::config::env("COMINDLY_AGENT");
1800    if let Ok(t) = crate::team::load(&server.ledger_root()).await {
1801        server.teams = Arc::new(std::sync::RwLock::new(t.settings));
1802    }
1803    let service = server.serve(rmcp::transport::io::stdio()).await?;
1804    service.waiting().await?;
1805    Ok(())
1806}
1807
1808/// Load the graph, enrichment, conventions and search model for `uri` into a server.
1809pub async fn load(uri: &str, markdown: bool, fresh: std::time::Duration) -> Result<ComindlyServer> {
1810    let (symbols, edges) = crate::index::read_graph(uri).await?;
1811    let mut conventions = match crate::index::read_facts(uri).await {
1812        Ok(Some(facts)) => crate::conventions::mine(&symbols, &edges, &facts),
1813        _ => Vec::new(),
1814    };
1815    conventions.extend(crate::index::read_recipes(uri).await.unwrap_or_default());
1816    let root = uri.trim_end_matches('/');
1817    let root = root.strip_suffix("/_graph").unwrap_or(root);
1818    let manual = match crate::conventions::Store::open(root) {
1819        Ok(s) => s.all().await.unwrap_or_default(),
1820        Err(_) => Vec::new(),
1821    };
1822    let conventions = crate::conventions::apply(conventions, &manual);
1823    let graph = Arc::new(CodeGraph::build(&symbols, &edges));
1824    let by_id: HashMap<String, Symbol> =
1825        symbols.iter().map(|s| (s.id.render(), s.clone())).collect();
1826
1827    let enrichment: Enrichment = crate::index::read_enrichment(uri)
1828        .await
1829        .ok()
1830        .flatten()
1831        .unwrap_or_default()
1832        .into_iter()
1833        .map(|(id, s, q)| (id.render(), (s, q)))
1834        .collect();
1835
1836    // Narrative style guides, one per repository.
1837    let style_guides = crate::index::read_style_guide(uri)
1838        .await
1839        .unwrap_or_default();
1840
1841    let flows: HashMap<String, (String, Vec<String>)> = crate::index::read_flows(uri)
1842        .await
1843        .ok()
1844        .flatten()
1845        .unwrap_or_default()
1846        .into_iter()
1847        .map(|(id, narr, q)| (id.render(), (narr, q)))
1848        .collect();
1849
1850    // The embedder powers `search`; graph tools work without it, so a load failure is non-fatal.
1851    let model = crate::config::index_settings(root).await.embed_model;
1852    let embedder = match tokio::task::spawn_blocking(move || crate::embed::Embedder::shared(&model))
1853        .await
1854        .map_err(anyhow::Error::from)
1855        .and_then(|r| r)
1856    {
1857        Ok(e) => Some(e),
1858        Err(e) => {
1859            crate::ui::warn(&format!(
1860                "embedding model unavailable, `search` disabled: {e:#}"
1861            ));
1862            None
1863        }
1864    };
1865
1866    let server = ComindlyServer {
1867        graph,
1868        enrichment: Arc::new(enrichment),
1869        style_guides: Arc::new(style_guides),
1870        flows: Arc::new(flows),
1871        symbols: Arc::new(by_id),
1872        embedder,
1873        uri: Arc::new(uri.to_string()),
1874        tables: Arc::new(crate::index::Tables::connect(uri, fresh).await?),
1875        records: Arc::new(tokio::sync::Mutex::new(None)),
1876        packages: Arc::new(crate::index::read_packages(uri).await.unwrap_or_default()),
1877        conventions: Arc::new(conventions),
1878        edges: Arc::new(edges),
1879        markdown,
1880        signer: None,
1881        identity: Default::default(),
1882        agent_name: None,
1883        teams: Default::default(),
1884    };
1885    Ok(server)
1886}
1887
1888/// The server agents reach over HTTP; empty until an index exists, replaced after each re-index.
1889pub type Shared = Arc<std::sync::RwLock<Option<ComindlyServer>>>;
1890
1891/// MCP over streamable HTTP for `hosts` (the server's own names), answering from `current`.
1892pub fn http(
1893    current: Shared,
1894    hosts: Vec<String>,
1895) -> rmcp::transport::streamable_http_server::StreamableHttpService<
1896    ComindlyServer,
1897    rmcp::transport::streamable_http_server::session::local::LocalSessionManager,
1898> {
1899    let config = rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default()
1900        .with_allowed_hosts(hosts);
1901    rmcp::transport::streamable_http_server::StreamableHttpService::new(
1902        move || {
1903            current.read().ok().and_then(|s| s.clone()).ok_or_else(|| {
1904                std::io::Error::other("no index yet: an admin indexes repositories first")
1905            })
1906        },
1907        Default::default(),
1908        config,
1909    )
1910}
1911
1912/// The MCP tools this server offers: `(name, description)`.
1913pub fn tools() -> Vec<(String, String)> {
1914    ComindlyServer::tool_router()
1915        .list_all()
1916        .into_iter()
1917        .map(|t| {
1918            (
1919                t.name.to_string(),
1920                t.description.unwrap_or_default().to_string(),
1921            )
1922        })
1923        .collect()
1924}
1925
1926#[cfg(test)]
1927mod tests {
1928    use super::*;
1929
1930    #[test]
1931    fn a_contract_lists_each_symbol_once_with_its_id() {
1932        let node = |id: &str| NodeDto {
1933            id: id.into(),
1934            name: id.into(),
1935            kind: "function".into(),
1936            repo: "worker".into(),
1937            location: "w.py:1".into(),
1938            signature: None,
1939            summary: Some("long summary".into()),
1940            queries: vec!["a question".into()],
1941        };
1942        let md = md_contracts(&ContractsDto {
1943            contracts: vec![ContractDto {
1944                contract: "sqs:tasks".into(),
1945                providers: vec![node("retry"), node("send")],
1946                consumers: vec![node("retry"), node("run")],
1947            }],
1948        });
1949        assert_eq!(md.matches("**retry**").count(), 1);
1950        assert!(md.contains("provides and uses: **retry**"));
1951        assert!(md.contains("used by: **run** (worker) `w.py:1` · id `run`"));
1952        assert!(!md.contains("long summary") && !md.contains("a question"));
1953    }
1954
1955    #[test]
1956    fn vendored_code_is_third_party() {
1957        assert!(third_party("lib/_vendor/aioboto3/session.py"));
1958        assert!(third_party("web/node_modules/x/index.js"));
1959        assert!(!third_party("src/vendors.py"));
1960    }
1961
1962    #[test]
1963    fn impact_by_team() {
1964        let mut teams = crate::team::TeamSettings::default();
1965        teams.label("api", &["Backend".into(), "Payments".into()]);
1966        teams.label("web", &["Frontend".into()]);
1967        let by_repo = [
1968            ("api".to_string(), 3),
1969            ("web".into(), 1),
1970            ("tools".into(), 2),
1971        ]
1972        .into_iter()
1973        .collect();
1974        let got: Vec<(String, usize, usize)> = by_team(&teams, &by_repo)
1975            .into_iter()
1976            .map(|t| (t.team, t.repos, t.symbols))
1977            .collect();
1978        assert_eq!(
1979            got,
1980            [
1981                ("Backend".into(), 1, 3),
1982                ("Payments".into(), 1, 3),
1983                ("none".into(), 1, 2),
1984                ("Frontend".into(), 1, 1)
1985            ]
1986        );
1987        assert!(by_team(&Default::default(), &by_repo).is_empty());
1988    }
1989}