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.agent_name.clone().or_else(|| client_name(ctx));
149        let who = ctx
150            .extensions
151            .get::<axum::http::request::Parts>()
152            .and_then(|parts| parts.extensions.get::<crate::server::Who>());
153        let identity = match who {
154            Some(w) => crate::ledger::Identity {
155                actor: format!("actor-{}", &blake3::hash(w.email.as_bytes()).to_hex()[..10]),
156                name: Some(w.email.clone()),
157            },
158            None => self.identity.clone(),
159        };
160        let mut p = crate::ledger::Provenance::agent("mcp", name, &identity);
161        if let Some(w) = who {
162            p.teams = self.team_settings().teams_of_member(&w.email);
163        }
164        p
165    }
166
167    /// Trusted (T2+), still-relevant ledger records about these symbols, as compact lines.
168    async fn known(&self, ids: &[String], limit: usize) -> Vec<String> {
169        let records = self.records().await;
170        if records.is_empty() {
171            return Vec::new();
172        }
173        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
174        let entries = self.entries(&records, &symbols);
175        let mut about = crate::ledger::about(&entries, ids);
176        // The owners' notes first; notes from other teams after them.
177        about.sort_by_key(|e| !e.outside.is_empty());
178        about
179            .into_iter()
180            .filter(|e| e.trust >= crate::ledger::Trust::T2)
181            .filter(|e| {
182                matches!(
183                    e.status,
184                    crate::ledger::Status::Active
185                        | crate::ledger::Status::Moved
186                        | crate::ledger::Status::Stale
187                )
188            })
189            .take(limit)
190            .map(crate::ledger::line)
191            .collect()
192    }
193
194    async fn write_record(
195        &self,
196        p: RememberParam,
197        by: crate::ledger::Provenance,
198    ) -> anyhow::Result<String> {
199        let kind = crate::ledger::Kind::parse(&p.kind)
200            .ok_or_else(|| anyhow::anyhow!("unknown kind `{}`", p.kind))?;
201        let commits = crate::index::read_repo_commits(&self.uri)
202            .await
203            .unwrap_or_default();
204        let mut anchors = Vec::new();
205        for focus in &p.on {
206            let idx = self
207                .graph
208                .lookup(focus)
209                .ok_or_else(|| anyhow::anyhow!("no symbol matches `{focus}` — use `find` first"))?;
210            let id = self.graph.node(idx).id;
211            let s = self
212                .symbols
213                .get(&id)
214                .ok_or_else(|| anyhow::anyhow!("symbol `{id}` not loaded"))?;
215            anchors.push(crate::ledger::Anchor::of(s).at(&commits));
216        }
217        let repos: Vec<&str> = anchors.iter().map(|a| a.repo.as_str()).collect();
218        let teams = self.team_settings();
219        teams.accepts(&by.teams, &repos)?;
220        let outside = teams.outside(&by.teams, &repos);
221        let commit = anchors.first().and_then(|a| a.commit.clone());
222        let mut rec = crate::ledger::Record::new(kind, &p.title, anchors, by);
223        rec.snippets = p
224            .snippets
225            .into_iter()
226            .map(|s| crate::ledger::Snippet {
227                path: s.path,
228                code: s.code,
229                commit: s.commit.or_else(|| commit.clone()),
230            })
231            .collect();
232        rec.body = p.body.unwrap_or_default();
233        rec.trigger = p.trigger;
234        rec.evidence = p.evidence;
235        rec.fields = p
236            .fields
237            .into_iter()
238            .map(|(k, v)| (k, serde_json::Value::String(v)))
239            .collect();
240        crate::ledger::Ledger::open(&self.ledger_root())?
241            .signed_by(self.signer.clone())
242            .put(&rec)
243            .await?;
244        self.wrote(&rec).await;
245        let until = match outside {
246            Some(owners) => format!("a member of {} reviews it", owners.join(" or ")),
247            None => "a human reviews it".into(),
248        };
249        Ok(format!(
250            "recorded {} `{}` (id `{}`); it is unconfirmed until {until}",
251            kind.as_str(),
252            rec.title,
253            rec.id
254        ))
255    }
256
257    async fn write_review(
258        &self,
259        p: ReviewParam,
260        by: crate::ledger::Provenance,
261    ) -> anyhow::Result<String> {
262        let ledger =
263            crate::ledger::Ledger::open(&self.ledger_root())?.signed_by(self.signer.clone());
264        let records = self.records().await;
265        anyhow::ensure!(
266            p.id.len() >= 6,
267            "give at least the first 6 characters of the record id"
268        );
269        let matches: Vec<_> = records
270            .iter()
271            .filter(|r| r.id == p.id || r.id.starts_with(&p.id))
272            .collect();
273        let target = match matches.as_slice() {
274            [one] => *one,
275            [] => anyhow::bail!("no ledger record `{}`", p.id),
276            _ => anyhow::bail!(
277                "`{}` matches {} records; give more of the id",
278                p.id,
279                matches.len()
280            ),
281        };
282        let reason = p.reason.as_deref().unwrap_or_default();
283        let rec = crate::ledger::Record::review(target, &p.action, reason, by)?;
284        ledger.put(&rec).await?;
285        self.wrote(&rec).await;
286        Ok(format!("{}: `{}`", p.action, target.title))
287    }
288
289    /// The repo a focus names: a repo itself, a file path, or a symbol.
290    fn repo_of(&self, focus: &str) -> Option<String> {
291        if self.graph.repos().iter().any(|(r, _)| r == focus) {
292            return Some(focus.to_string());
293        }
294        if let Some(s) = self
295            .symbols
296            .values()
297            .find(|s| s.kind == crate::model::SymbolKind::File && s.file_path.ends_with(focus))
298        {
299            return Some(s.repo.0.clone());
300        }
301        self.graph.lookup(focus).map(|i| self.graph.node(i).repo)
302    }
303
304    /// Build a result node, attaching its LLM summary when available.
305    fn node_dto(&self, n: GNode) -> NodeDto {
306        let enr = self.enrichment.get(&n.id);
307        NodeDto {
308            id: n.id,
309            name: n.name,
310            kind: n.kind,
311            repo: n.repo,
312            location: n.location,
313            signature: n.signature,
314            summary: enr.map(|(s, _)| s.clone()),
315            queries: enr.map(|(_, q)| q.clone()).unwrap_or_default(),
316        }
317    }
318
319    /// Wrap a tool DTO into a result: markdown text (default), or JSON text with the same JSON as
320    /// structured content.
321    fn reply<T: Serialize>(&self, dto: &T, md: String) -> CallToolResult {
322        let value = serde_json::to_value(dto).ok();
323        let text = if self.markdown {
324            md
325        } else {
326            value
327                .as_ref()
328                .and_then(|v| serde_json::to_string_pretty(v).ok())
329                .unwrap_or_default()
330        };
331        let mut r = CallToolResult::success(vec![ContentBlock::text(text)]);
332        // Clients such as Claude Code show structured content instead of the text when both come.
333        if !self.markdown {
334            r.structured_content = value;
335        }
336        r
337    }
338}
339
340// ---- DTOs (serialized to the agent) -----------------------------------------------------
341
342#[derive(Serialize, schemars::JsonSchema)]
343struct NodeDto {
344    id: String,
345    name: String,
346    kind: String,
347    repo: String,
348    /// `path:line` — jump straight here.
349    location: String,
350    signature: Option<String>,
351    /// One-line LLM summary, when the index was enriched.
352    summary: Option<String>,
353    /// Natural-language questions this symbol answers (pre-generated during `--enrich`).
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    queries: Vec<String>,
356}
357
358#[derive(Serialize, schemars::JsonSchema)]
359struct HopDto {
360    name: String,
361    kind: String,
362    repo: String,
363    location: String,
364    depth: usize,
365    via: String,
366}
367
368#[derive(Serialize, schemars::JsonSchema)]
369struct RepoCount {
370    repo: String,
371    symbols: usize,
372    /// Teams labelling the repository.
373    #[serde(skip_serializing_if = "Vec::is_empty")]
374    teams: Vec<String>,
375    /// Packages published by other indexed repos that this repo depends on.
376    #[serde(skip_serializing_if = "Vec::is_empty")]
377    depends_on: Vec<DepDto>,
378}
379
380#[derive(Serialize, schemars::JsonSchema)]
381struct DepDto {
382    package: String,
383    publisher: String,
384    published: Option<String>,
385    locked: Option<String>,
386    /// Pinned to a different version than the publisher's source.
387    skew: bool,
388}
389
390#[derive(Serialize, schemars::JsonSchema)]
391struct ReposDto {
392    repos: Vec<RepoCount>,
393    /// The org's teams.
394    #[serde(skip_serializing_if = "Vec::is_empty")]
395    teams: Vec<String>,
396}
397
398#[derive(Serialize, schemars::JsonSchema)]
399struct TeamCount {
400    team: String,
401    repos: usize,
402    symbols: usize,
403}
404
405#[derive(Serialize, schemars::JsonSchema)]
406struct FindDto {
407    results: Vec<NodeDto>,
408}
409
410#[derive(Serialize, schemars::JsonSchema)]
411struct ThreadDto {
412    focus: String,
413    trace: Vec<HopDto>,
414}
415
416#[derive(Serialize, schemars::JsonSchema)]
417struct FlowDto {
418    focus: String,
419    found: bool,
420    /// Pre-generated LLM walkthrough (present only if the index was built with `--flows`).
421    narration: Option<String>,
422    queries: Vec<String>,
423    trace: Vec<HopDto>,
424}
425
426#[derive(Serialize, schemars::JsonSchema)]
427struct ZoomDto {
428    found: bool,
429    focus: Option<NodeDto>,
430    container: Option<NodeDto>,
431    callers: Vec<NodeDto>,
432    callees: Vec<NodeDto>,
433    importers: Vec<NodeDto>,
434    members: Vec<NodeDto>,
435    /// Service contracts (HTTP routes, queues, buckets, tables) this symbol takes part in.
436    contracts: Vec<ContractRoleDto>,
437    /// When the focus is a contract: its providers and consumers.
438    providers: Vec<NodeDto>,
439    consumers: Vec<NodeDto>,
440    /// Recorded constraints, failed attempts and decisions about this symbol or its containers.
441    known: Vec<String>,
442}
443
444#[derive(Serialize, schemars::JsonSchema)]
445struct ContractRoleDto {
446    role: String,
447    contract: String,
448    /// Participants on the other side (consumers of what it serves, providers of what it uses).
449    counterparts: Vec<NodeDto>,
450}
451
452#[derive(Serialize, schemars::JsonSchema)]
453struct ContractDto {
454    contract: String,
455    providers: Vec<NodeDto>,
456    consumers: Vec<NodeDto>,
457}
458
459#[derive(Serialize, schemars::JsonSchema)]
460struct ContractsDto {
461    contracts: Vec<ContractDto>,
462}
463
464#[derive(Deserialize, schemars::JsonSchema, Default)]
465struct ContractsParam {
466    /// Only this contract type: `http`, `sqs`, `sns`, `s3` or `table`.
467    #[serde(default)]
468    kind: Option<String>,
469    /// Only contracts missing a provider or consumer (calls to nothing, unused routes).
470    #[serde(default)]
471    dangling: Option<bool>,
472    /// Substring filter on the contract key, e.g. `/courses` or `orders`.
473    #[serde(default)]
474    query: Option<String>,
475    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
476    #[serde(default)]
477    team: Option<String>,
478}
479
480#[derive(Serialize, schemars::JsonSchema)]
481struct RippleDto {
482    focus: String,
483    found: bool,
484    total_dependents: usize,
485    by_repo: Vec<RepoCount>,
486    /// Dependents per team; unlabelled repositories count under `none`.
487    #[serde(skip_serializing_if = "Vec::is_empty")]
488    by_team: Vec<TeamCount>,
489    dependents: Vec<HopDto>,
490}
491
492#[derive(Serialize, schemars::JsonSchema)]
493struct PackItem {
494    name: String,
495    location: String,
496    est_tokens: usize,
497    summary: Option<String>,
498}
499
500#[derive(Serialize, schemars::JsonSchema)]
501struct GuideDto {
502    found: bool,
503    /// Repo the conventions are scoped to (from `focus`), if any.
504    repo: Option<String>,
505    /// Measured conventions for the repo, then the org baseline.
506    conventions: Vec<ConventionDto>,
507    /// LLM-written narrative guide (present only if the index was built with `--guide`).
508    guide: Option<String>,
509}
510
511#[derive(Serialize, schemars::JsonSchema)]
512struct ConventionDto {
513    scope: String,
514    dimension: String,
515    rule: String,
516    /// How strictly the code follows it: `Always`, `Usually`, `Often` or `Sometimes`.
517    #[serde(skip_serializing_if = "Option::is_none")]
518    strength: Option<&'static str>,
519    /// What it was measured from.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    evidence: Option<String>,
522    exemplars: Vec<String>,
523    counterexamples: Vec<String>,
524    /// Written or edited by a person rather than measured.
525    #[serde(skip_serializing_if = "std::ops::Not::not")]
526    team: bool,
527}
528
529#[derive(Deserialize, schemars::JsonSchema, Default)]
530struct RememberParam {
531    /// `decision` | `attempt` | `constraint` | `gotcha` | `intent` | `question`.
532    kind: String,
533    /// Symbols (ids or names) the record is about.
534    on: Vec<String>,
535    /// One neutral, factual line.
536    title: String,
537    #[serde(default)]
538    body: Option<String>,
539    /// When a future engineer would need this.
540    #[serde(default)]
541    trigger: Option<String>,
542    /// Evidence: `commit:<sha>`, `mr:!123`, `test:<path>::<name>`, error text… Required for `attempt`.
543    #[serde(default)]
544    evidence: Vec<String>,
545    /// Kind-specific fields, e.g. `{"outcome": "reverted"}`, `{"alternatives": "…"}`.
546    #[serde(default)]
547    fields: std::collections::BTreeMap<String, String>,
548    /// Up to 3 short excerpts (60 lines each) of the code that shaped this, as it is now.
549    #[serde(default)]
550    snippets: Vec<SnippetParam>,
551}
552
553#[derive(Deserialize, schemars::JsonSchema, Default)]
554struct SnippetParam {
555    /// File path, e.g. `app/auth/session.py`.
556    path: String,
557    code: String,
558    /// The commit the code is from; defaults to the indexed commit.
559    #[serde(default)]
560    commit: Option<String>,
561}
562
563#[derive(Deserialize, schemars::JsonSchema, Default)]
564struct PreflightParam {
565    /// Files you plan to change (repo-relative or absolute paths).
566    #[serde(default)]
567    files: Vec<String>,
568    /// Symbols you plan to change (ids or names).
569    #[serde(default)]
570    symbols: Vec<String>,
571}
572
573#[derive(Deserialize, schemars::JsonSchema, Default)]
574struct ReviewParam {
575    /// Record id (or a unique prefix) from `why`.
576    id: String,
577    /// `confirm` | `archive` (no longer applies) | `invalidate` (was wrong) | `supersede` | `dispute`.
578    action: String,
579    #[serde(default)]
580    reason: Option<String>,
581}
582
583#[derive(Serialize, schemars::JsonSchema)]
584struct LedgerDto {
585    ok: bool,
586    message: String,
587}
588
589#[derive(Deserialize, schemars::JsonSchema, Default)]
590struct GuideParam {
591    /// A repo name, file path or symbol: scopes conventions to its repo. Omit for org-wide.
592    #[serde(default)]
593    focus: Option<String>,
594}
595
596#[derive(Serialize, schemars::JsonSchema)]
597struct PackDto {
598    focus: String,
599    found: bool,
600    est_total_tokens: usize,
601    token_budget: usize,
602    items: Vec<PackItem>,
603    /// Recorded constraints and failed attempts about the focus and the packed symbols.
604    known: Vec<String>,
605}
606
607#[derive(Serialize, schemars::JsonSchema)]
608struct SearchHitDto {
609    /// Stable handle to pass to zoom/ripple/thread/flow/context_pack.
610    id: String,
611    name: String,
612    kind: String,
613    repo: String,
614    location: String,
615    signature: Option<String>,
616    score: f32,
617    deps: usize,
618    summary: Option<String>,
619}
620
621#[derive(Serialize, schemars::JsonSchema)]
622struct SearchDto {
623    query: String,
624    results: Vec<SearchHitDto>,
625}
626
627#[derive(Serialize, schemars::JsonSchema)]
628struct SuggestItem {
629    query: String,
630    symbol: String,
631    location: String,
632}
633
634#[derive(Serialize, schemars::JsonSchema)]
635struct SuggestDto {
636    total: usize,
637    suggestions: Vec<SuggestItem>,
638}
639
640// ---- tool parameters --------------------------------------------------------------------
641
642#[derive(Deserialize, schemars::JsonSchema, Default)]
643struct ReposParam {
644    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
645    #[serde(default)]
646    team: Option<String>,
647}
648
649#[derive(Deserialize, schemars::JsonSchema, Default)]
650struct SearchParam {
651    /// A natural-language question ("how do we create a migration?") or a symbol name.
652    query: String,
653    /// Max results (default 12).
654    limit: Option<u32>,
655    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
656    #[serde(default)]
657    team: Option<String>,
658}
659
660#[derive(Deserialize, schemars::JsonSchema, Default)]
661struct SuggestParam {
662    /// Optional keyword to filter suggestions (matches the question or the symbol name).
663    about: Option<String>,
664    /// Max suggestions (default 40).
665    limit: Option<u32>,
666    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
667    #[serde(default)]
668    team: Option<String>,
669}
670
671#[derive(Deserialize, schemars::JsonSchema, Default)]
672struct FindParam {
673    /// Symbol name or descriptor/path substring.
674    query: String,
675    /// Max results (default 20).
676    limit: Option<u32>,
677    /// Only repositories of these teams: comma-separated names, `none` for unlabelled ones. Omit for the whole org.
678    #[serde(default)]
679    team: Option<String>,
680}
681
682#[derive(Deserialize, schemars::JsonSchema, Default)]
683struct FocusParam {
684    /// A symbol name or SCIP descriptor (e.g. `acme/const/Settings`).
685    focus: String,
686}
687
688#[derive(Deserialize, schemars::JsonSchema, Default)]
689struct DepthParam {
690    /// A symbol name or SCIP descriptor (from `find`/`search`, e.g. `acme/const/Settings`).
691    focus: String,
692    /// Max hops to traverse (default 4).
693    depth: Option<u32>,
694}
695
696#[derive(Deserialize, schemars::JsonSchema, Default)]
697struct PackParam {
698    /// A symbol name or SCIP descriptor (from `find`/`search`, e.g. `acme/const/Settings`).
699    focus: String,
700    /// Token budget for the read-set (default 1500).
701    token_budget: Option<u32>,
702}
703
704// ---- tools ------------------------------------------------------------------------------
705
706#[tool_router(vis = "pub(crate)")]
707impl ComindlyServer {
708    #[tool(
709        name = "search",
710        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."
711    )]
712    async fn search(&self, Parameters(p): Parameters<SearchParam>) -> CallToolResult {
713        let limit = p.limit.unwrap_or(12) as usize;
714        let Some(embedder) = self.embedder.as_deref() else {
715            let dto = SearchDto {
716                query: p.query.clone(),
717                results: vec![],
718            };
719            return self.reply(
720                &dto,
721                "_search unavailable: the embedding model could not be loaded (offline?). Graph tools (find/zoom/ripple/thread/context_pack) still work._".into(),
722            );
723        };
724        let hits = crate::search::hybrid(
725            &self.tables,
726            &self.symbols,
727            &self.graph,
728            &self.enrichment,
729            embedder,
730            &p.query,
731            if p.team.is_some() { limit * 5 } else { limit },
732        )
733        .await
734        .unwrap_or_default();
735        let hits: Vec<_> = hits
736            .into_iter()
737            .filter(|h| self.in_team(p.team.as_deref(), &h.repo))
738            .take(limit)
739            .collect();
740        let md = crate::search::markdown(&p.query, &hits);
741        let dto = SearchDto {
742            query: p.query.clone(),
743            results: hits.iter().map(hit_dto).collect(),
744        };
745        self.reply(&dto, md)
746    }
747
748    #[tool(
749        name = "repos",
750        description = "List indexed repositories with their symbol counts and the teams labelling each; `team` narrows to some teams"
751    )]
752    fn repos(&self, Parameters(p): Parameters<ReposParam>) -> CallToolResult {
753        let teams = self.team_settings();
754        let dto = ReposDto {
755            repos: self
756                .graph
757                .repos()
758                .into_iter()
759                .filter(|(repo, _)| self.in_team(p.team.as_deref(), repo))
760                .map(|(repo, symbols)| RepoCount {
761                    depends_on: comindly_deps(&self.packages, &repo),
762                    teams: teams.teams_of(&repo),
763                    repo,
764                    symbols,
765                })
766                .collect(),
767            teams: teams.team_list().iter().map(|t| t.name.clone()).collect(),
768        };
769        let md = md_repos(&dto);
770        self.reply(&dto, md)
771    }
772
773    #[tool(
774        name = "suggest",
775        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."
776    )]
777    fn suggest(&self, Parameters(p): Parameters<SuggestParam>) -> CallToolResult {
778        let limit = p.limit.unwrap_or(40) as usize;
779        let about = p.about.as_deref().map(str::to_lowercase);
780        let mut items: Vec<(usize, SuggestItem)> = Vec::new();
781        for (id, (_, queries)) in self.enrichment.iter() {
782            if self
783                .symbols
784                .get(id)
785                .is_some_and(|s| third_party(&s.file_path))
786            {
787                continue;
788            }
789            let used = self.graph.dependents_count(id);
790            if p.team.is_some()
791                && !self
792                    .symbols
793                    .get(id)
794                    .is_some_and(|s| self.in_team(p.team.as_deref(), &s.repo.0))
795            {
796                continue;
797            }
798            let (symbol, location) = self
799                .symbols
800                .get(id)
801                .map(|s| {
802                    (
803                        s.name.clone(),
804                        format!("{}:{}", s.file_path, s.range.start.line),
805                    )
806                })
807                .unwrap_or_else(|| (id.clone(), String::new()));
808            for q in queries {
809                if let Some(a) = &about {
810                    if !q.to_lowercase().contains(a) && !symbol.to_lowercase().contains(a) {
811                        continue;
812                    }
813                }
814                items.push((
815                    used,
816                    SuggestItem {
817                        query: q.clone(),
818                        symbol: symbol.clone(),
819                        location: location.clone(),
820                    },
821                ));
822            }
823        }
824        // The most depended-on code first: what a newcomer most needs to understand.
825        items.sort_by(|(ua, a), (ub, b)| {
826            ub.cmp(ua)
827                .then(a.symbol.cmp(&b.symbol))
828                .then(a.query.cmp(&b.query))
829        });
830        let total = items.len();
831        let items: Vec<SuggestItem> = items.into_iter().take(limit).map(|(_, i)| i).collect();
832        let dto = SuggestDto {
833            total,
834            suggestions: items,
835        };
836        let md = md_suggest(&dto);
837        self.reply(&dto, md)
838    }
839
840    #[tool(
841        name = "find",
842        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."
843    )]
844    fn find(&self, Parameters(p): Parameters<FindParam>) -> CallToolResult {
845        let limit = p.limit.unwrap_or(20) as usize;
846        let dto = FindDto {
847            results: self
848                .graph
849                .find(&p.query, if p.team.is_some() { limit * 5 } else { limit })
850                .into_iter()
851                .filter(|n| self.in_team(p.team.as_deref(), &n.repo))
852                .take(limit)
853                .map(|n| self.node_dto(n))
854                .collect(),
855        };
856        let md = md_find(&p.query, &dto);
857        self.reply(&dto, md)
858    }
859
860    #[tool(
861        name = "zoom",
862        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."
863    )]
864    async fn zoom(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
865        let dto = match self.graph.lookup(&p.focus) {
866            None => ZoomDto {
867                found: false,
868                focus: None,
869                container: None,
870                callers: vec![],
871                callees: vec![],
872                importers: vec![],
873                members: vec![],
874                contracts: vec![],
875                providers: vec![],
876                consumers: vec![],
877                known: vec![],
878            },
879            Some(idx) => {
880                let z = self.graph.zoom(idx);
881                ZoomDto {
882                    found: true,
883                    focus: z.focus.map(|n| self.node_dto(n)),
884                    container: z.container.map(|n| self.node_dto(n)),
885                    callers: z.callers.into_iter().map(|n| self.node_dto(n)).collect(),
886                    callees: z.callees.into_iter().map(|n| self.node_dto(n)).collect(),
887                    importers: z.importers.into_iter().map(|n| self.node_dto(n)).collect(),
888                    members: z.members.into_iter().map(|n| self.node_dto(n)).collect(),
889                    contracts: z
890                        .contracts
891                        .into_iter()
892                        .map(|c| ContractRoleDto {
893                            role: c.role.to_string(),
894                            contract: c.contract.name,
895                            counterparts: c
896                                .counterparts
897                                .into_iter()
898                                .map(|n| self.node_dto(n))
899                                .collect(),
900                        })
901                        .collect(),
902                    providers: z.providers.into_iter().map(|n| self.node_dto(n)).collect(),
903                    consumers: z.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
904                    known: match self.graph.scope_of(&p.focus) {
905                        Some((_, ids)) => self.known(&ids, 5).await,
906                        None => vec![],
907                    },
908                }
909            }
910        };
911        let md = md_zoom(&p.focus, &dto);
912        self.reply(&dto, md)
913    }
914
915    #[tool(
916        name = "contracts",
917        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."
918    )]
919    fn contracts(&self, Parameters(p): Parameters<ContractsParam>) -> CallToolResult {
920        let views = self
921            .graph
922            .contracts(p.kind.as_deref(), p.dangling.unwrap_or(false));
923        let dto = ContractsDto {
924            contracts: views
925                .into_iter()
926                .filter(|v| {
927                    p.query
928                        .as_deref()
929                        .is_none_or(|q| v.contract.name.contains(q))
930                })
931                .filter(|v| {
932                    p.team.is_none()
933                        || v.providers
934                            .iter()
935                            .chain(&v.consumers)
936                            .any(|n| self.in_team(p.team.as_deref(), &n.repo))
937                })
938                .take(200)
939                .map(|v| ContractDto {
940                    contract: v.contract.name,
941                    providers: v.providers.into_iter().map(|n| self.node_dto(n)).collect(),
942                    consumers: v.consumers.into_iter().map(|n| self.node_dto(n)).collect(),
943                })
944                .collect(),
945        };
946        let md = md_contracts(&dto);
947        self.reply(&dto, md)
948    }
949
950    #[tool(
951        name = "why",
952        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."
953    )]
954    async fn why(&self, Parameters(p): Parameters<FocusParam>) -> CallToolResult {
955        let Some((name, ids)) = self.graph.scope_of(&p.focus) else {
956            let dto = LedgerDto {
957                ok: false,
958                message: format!("no symbol matches `{}`", p.focus),
959            };
960            let md = format!("_{}_", dto.message);
961            return self.reply(&dto, md);
962        };
963        let records = self.records().await;
964        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
965        let entries = self.entries(&records, &symbols);
966        let about = crate::ledger::about(&entries, &ids);
967        let md = crate::ledger::markdown(&format!("why `{name}`"), &about);
968        let dto = LedgerDto {
969            ok: true,
970            message: md.clone(),
971        };
972        self.reply(&dto, md)
973    }
974
975    #[tool(
976        name = "preflight",
977        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."
978    )]
979    async fn preflight(&self, Parameters(p): Parameters<PreflightParam>) -> CallToolResult {
980        let mut changes = Vec::new();
981        for f in &p.files {
982            if let Some(s) = self.symbols.values().find(|s| {
983                s.kind == crate::model::SymbolKind::File
984                    && (s.file_path == *f || f.ends_with(&format!("/{}", s.file_path)))
985            }) {
986                changes.push(crate::preflight::Change {
987                    repo: s.repo.0.clone(),
988                    path: s.file_path.clone(),
989                    lines: Vec::new(),
990                });
991            }
992        }
993        for focus in &p.symbols {
994            let Some(idx) = self.graph.lookup(focus) else {
995                continue;
996            };
997            if let Some(s) = self.symbols.get(&self.graph.node(idx).id) {
998                changes.push(crate::preflight::Change {
999                    repo: s.repo.0.clone(),
1000                    path: s.file_path.clone(),
1001                    lines: vec![(s.range.start.line, s.range.end.line)],
1002                });
1003            }
1004        }
1005        let records = self.records().await;
1006        let symbols: Vec<Symbol> = self.symbols.values().cloned().collect();
1007        let entries = self.entries(&records, &symbols);
1008        let findings = crate::checks::run(&entries, &symbols, &self.edges, &self.packages);
1009        let recipes: Vec<crate::conventions::Convention> = self
1010            .conventions
1011            .iter()
1012            .filter(|c| c.dimension == "recipes")
1013            .cloned()
1014            .collect();
1015        let md = if changes.is_empty() {
1016            "_no indexed files or symbols matched — pass repo-relative paths or names from `find`._"
1017                .to_string()
1018        } else {
1019            crate::preflight::report(
1020                &self.graph,
1021                &symbols,
1022                &self.edges,
1023                &entries,
1024                &recipes,
1025                &findings,
1026                &changes,
1027            ) + &crate::preflight::teams_affected(
1028                &self.graph,
1029                &symbols,
1030                &changes,
1031                &self.team_settings(),
1032            )
1033        };
1034        let dto = LedgerDto {
1035            ok: !changes.is_empty(),
1036            message: md.clone(),
1037        };
1038        self.reply(&dto, md)
1039    }
1040
1041    #[tool(
1042        name = "remember",
1043        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."
1044    )]
1045    async fn remember(
1046        &self,
1047        ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1048        Parameters(p): Parameters<RememberParam>,
1049    ) -> CallToolResult {
1050        let result = match Self::may_write(&ctx) {
1051            Ok(()) => self.write_record(p, self.agent(&ctx)).await,
1052            Err(e) => Err(e),
1053        };
1054        let dto = LedgerDto {
1055            ok: result.is_ok(),
1056            message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1057        };
1058        let md = dto.message.clone();
1059        self.reply(&dto, md)
1060    }
1061
1062    #[tool(
1063        name = "review",
1064        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."
1065    )]
1066    async fn review(
1067        &self,
1068        ctx: rmcp::service::RequestContext<rmcp::RoleServer>,
1069        Parameters(p): Parameters<ReviewParam>,
1070    ) -> CallToolResult {
1071        let result = match Self::may_write(&ctx) {
1072            Ok(()) => self.write_review(p, self.agent(&ctx)).await,
1073            Err(e) => Err(e),
1074        };
1075        let dto = LedgerDto {
1076            ok: result.is_ok(),
1077            message: result.unwrap_or_else(|e| format!("not recorded: {e:#}")),
1078        };
1079        let md = dto.message.clone();
1080        self.reply(&dto, md)
1081    }
1082
1083    #[tool(
1084        name = "ripple",
1085        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."
1086    )]
1087    fn ripple(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1088        let depth = p.depth.unwrap_or(4) as usize;
1089        let dto = match self.graph.lookup(&p.focus) {
1090            None => RippleDto {
1091                focus: p.focus.clone(),
1092                found: false,
1093                total_dependents: 0,
1094                by_repo: vec![],
1095                by_team: vec![],
1096                dependents: vec![],
1097            },
1098            Some(idx) => {
1099                let hops = self.graph.ripple(idx, depth);
1100                let teams = self.team_settings();
1101                let mut by_repo: std::collections::BTreeMap<String, usize> =
1102                    std::collections::BTreeMap::new();
1103                for h in &hops {
1104                    *by_repo.entry(h.node.repo.clone()).or_default() += 1;
1105                }
1106                RippleDto {
1107                    focus: p.focus.clone(),
1108                    found: true,
1109                    total_dependents: hops.len(),
1110                    by_team: by_team(&teams, &by_repo),
1111                    by_repo: by_repo
1112                        .into_iter()
1113                        .map(|(repo, symbols)| RepoCount {
1114                            teams: teams.teams_of(&repo),
1115                            repo,
1116                            symbols,
1117                            depends_on: Vec::new(),
1118                        })
1119                        .collect(),
1120                    dependents: hops.into_iter().map(hop_dto).collect(),
1121                }
1122            }
1123        };
1124        let md = md_ripple(&dto);
1125        self.reply(&dto, md)
1126    }
1127
1128    #[tool(
1129        name = "guide",
1130        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."
1131    )]
1132    fn guide(&self, Parameters(p): Parameters<GuideParam>) -> CallToolResult {
1133        let repo = p.focus.as_deref().and_then(|f| self.repo_of(f));
1134        let repo_keys: std::collections::HashSet<&str> = self
1135            .conventions
1136            .iter()
1137            .filter(|c| Some(&c.repo) == repo.as_ref())
1138            .map(|c| c.key.as_str())
1139            .collect();
1140        let mut conventions: Vec<ConventionDto> = Vec::new();
1141        for c in self.conventions.iter() {
1142            let scoped = Some(&c.repo) == repo.as_ref();
1143            let org = c.repo == crate::conventions::ORG && !repo_keys.contains(c.key.as_str());
1144            if scoped || org {
1145                conventions.push(ConventionDto {
1146                    scope: c.repo.clone(),
1147                    dimension: c.dimension.clone(),
1148                    rule: c.rule.clone(),
1149                    strength: c.strength(),
1150                    evidence: c.evidence(),
1151                    exemplars: c.exemplars.iter().take(2).cloned().collect(),
1152                    counterexamples: c.counterexamples.iter().take(2).cloned().collect(),
1153                    team: c.origin != crate::conventions::Origin::Measured,
1154                });
1155            }
1156        }
1157        // One repository's narrative guide; all of them together outgrow what a client shows.
1158        let guide = match &repo {
1159            Some(r) => self
1160                .style_guides
1161                .iter()
1162                .find(|(g, _)| g == r)
1163                .map(|(_, g)| g.clone()),
1164            None => (!self.style_guides.is_empty()).then(|| {
1165                let names: Vec<&str> = self.style_guides.iter().map(|(r, _)| r.as_str()).collect();
1166                format!(
1167                    "Written for each repository; pass `focus` to read one: {}.",
1168                    names.join(", ")
1169                )
1170            }),
1171        };
1172        let dto = GuideDto {
1173            found: !conventions.is_empty() || guide.is_some(),
1174            repo,
1175            conventions,
1176            guide,
1177        };
1178        let md = md_guide(&dto);
1179        self.reply(&dto, md)
1180    }
1181
1182    #[tool(
1183        name = "thread",
1184        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)."
1185    )]
1186    fn thread(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1187        let depth = p.depth.unwrap_or(4) as usize;
1188        let trace = match self.graph.lookup(&p.focus) {
1189            Some(idx) => self
1190                .graph
1191                .thread(idx, depth)
1192                .into_iter()
1193                .map(hop_dto)
1194                .collect(),
1195            None => vec![],
1196        };
1197        let dto = ThreadDto {
1198            focus: p.focus.clone(),
1199            trace,
1200        };
1201        let md = md_thread(&dto);
1202        self.reply(&dto, md)
1203    }
1204
1205    #[tool(
1206        name = "flow",
1207        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."
1208    )]
1209    fn flow(&self, Parameters(p): Parameters<DepthParam>) -> CallToolResult {
1210        let depth = p.depth.unwrap_or(4) as usize;
1211        let dto = match self.graph.lookup(&p.focus) {
1212            None => FlowDto {
1213                focus: p.focus.clone(),
1214                found: false,
1215                narration: None,
1216                queries: vec![],
1217                trace: vec![],
1218            },
1219            Some(idx) => {
1220                let fid = self.graph.zoom(idx).focus.map(|n| n.id);
1221                let (narration, queries) = fid
1222                    .as_ref()
1223                    .and_then(|id| self.flows.get(id))
1224                    .map(|(n, q)| (Some(n.clone()), q.clone()))
1225                    .unwrap_or((None, vec![]));
1226                let trace = self
1227                    .graph
1228                    .thread(idx, depth)
1229                    .into_iter()
1230                    .map(hop_dto)
1231                    .collect();
1232                FlowDto {
1233                    focus: p.focus.clone(),
1234                    found: true,
1235                    narration,
1236                    queries,
1237                    trace,
1238                }
1239            }
1240        };
1241        let md = md_flow(&dto);
1242        self.reply(&dto, md)
1243    }
1244
1245    #[tool(
1246        name = "context_pack",
1247        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."
1248    )]
1249    async fn context_pack(&self, Parameters(p): Parameters<PackParam>) -> CallToolResult {
1250        let budget = p.token_budget.unwrap_or(1500) as usize;
1251        let dto = match self.graph.lookup(&p.focus) {
1252            None => PackDto {
1253                focus: p.focus.clone(),
1254                found: false,
1255                est_total_tokens: 0,
1256                token_budget: budget,
1257                items: vec![],
1258                known: vec![],
1259            },
1260            Some(idx) => {
1261                let pack = self.graph.context_pack(idx, budget);
1262                let est_total_tokens = pack.iter().map(|(_, t)| t).sum();
1263                let mut ids: Vec<String> = pack.iter().map(|(n, _)| n.id.clone()).collect();
1264                if let Some((_, scope)) = self.graph.scope_of(&p.focus) {
1265                    ids.extend(scope);
1266                }
1267                PackDto {
1268                    focus: p.focus.clone(),
1269                    found: true,
1270                    est_total_tokens,
1271                    token_budget: budget,
1272                    known: self.known(&ids, 6).await,
1273                    items: pack
1274                        .into_iter()
1275                        .map(|(n, t)| PackItem {
1276                            summary: self.enrichment.get(&n.id).map(|(s, _)| s.clone()),
1277                            name: n.name,
1278                            location: n.location,
1279                            est_tokens: t,
1280                        })
1281                        .collect(),
1282                }
1283            }
1284        };
1285        let md = md_pack(&dto);
1286        self.reply(&dto, md)
1287    }
1288}
1289
1290impl ComindlyServer {
1291    /// The markdown a read-only tool gives an agent for these arguments, for the app's agent view.
1292    pub async fn as_agent(&self, tool: &str, args: serde_json::Value) -> Result<String> {
1293        let me = Self {
1294            markdown: true,
1295            ..self.clone()
1296        };
1297        macro_rules! args {
1298            () => {
1299                Parameters(serde_json::from_value(args)?)
1300            };
1301        }
1302        let r = match tool {
1303            "repos" => me.repos(args!()),
1304            "contracts" => me.contracts(args!()),
1305            "guide" => me.guide(args!()),
1306            "find" => me.find(args!()),
1307            "suggest" => me.suggest(args!()),
1308            "ripple" => me.ripple(args!()),
1309            "thread" => me.thread(args!()),
1310            "flow" => me.flow(args!()),
1311            "zoom" => me.zoom(args!()).await,
1312            "why" => me.why(args!()).await,
1313            "context_pack" => me.context_pack(args!()).await,
1314            _ => anyhow::bail!("`{tool}` is not a read-only tool"),
1315        };
1316        Ok(r.content
1317            .iter()
1318            .filter_map(|c| c.as_text().map(|t| t.text.as_str()))
1319            .collect::<Vec<_>>()
1320            .join("\n"))
1321    }
1322}
1323
1324#[rmcp::tool_handler(router = Self::tool_router())]
1325impl rmcp::ServerHandler for ComindlyServer {
1326    fn get_info(&self) -> rmcp::model::ServerConfig {
1327        rmcp::model::ServerConfig::new(
1328            rmcp::model::ServerCapabilities::builder()
1329                .enable_tools()
1330                .build(),
1331        )
1332        .with_server_info(rmcp::model::Implementation::from_build_env())
1333        .with_instructions(crate::agents::GUIDE)
1334    }
1335
1336    /// Each tool call, logged with the client's own name, so a review bot shows apart from an editor.
1337    async fn call_tool(
1338        &self,
1339        request: rmcp::model::CallToolRequestParams,
1340        context: rmcp::service::RequestContext<rmcp::RoleServer>,
1341    ) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
1342        let tool = request.name.to_string();
1343        let parts = context.extensions.get::<axum::http::request::Parts>();
1344        let client = client_name(&context).unwrap_or_default();
1345        let email = parts
1346            .and_then(|p| p.extensions.get::<crate::server::Who>())
1347            .map(|w| w.email.clone());
1348        let start = std::time::Instant::now();
1349        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
1350        let out = Self::tool_router().call(tcc).await;
1351        let ms = start.elapsed().as_millis() as u64;
1352        let ok = out.is_ok();
1353        if !crate::telemetry::logs("tool") {
1354            return out;
1355        }
1356        tracing::info!(
1357            kind = "tool",
1358            tool = %tool,
1359            client = %client,
1360            email = email.as_deref(),
1361            ok,
1362            duration_ms = ms,
1363            "tool {tool} by {} {ms} ms",
1364            if client.is_empty() { "an unnamed client" } else { client.as_str() }
1365        );
1366        out
1367    }
1368}
1369
1370/// The app making a call: the gateway's name for its OAuth client, else the client's own, unless the
1371/// MCP library filled in its own name for a request without a session.
1372fn client_name(ctx: &rmcp::service::RequestContext<rmcp::RoleServer>) -> Option<String> {
1373    ctx.extensions
1374        .get::<axum::http::request::Parts>()
1375        .and_then(|p| p.extensions.get::<crate::server::GatewayClient>())
1376        .map(|c| c.0.clone())
1377        .or_else(|| {
1378            let own = rmcp::model::Implementation::from_build_env().name;
1379            ctx.peer
1380                .peer_info()
1381                .map(|p| p.client_info.name.clone())
1382                .filter(|n| *n != own)
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}