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