code-moniker 0.4.0

Standalone CLI / linter for the code-moniker symbol graph: file and directory probes, project-wide architecture rules.
Documentation
use std::fmt::Write as _;

use code_moniker_query::{
	Page, Query, QueryRequest, QueryResult, SymbolGraphFocus, SymbolGraphNeighbor,
	SymbolGraphQuery, SymbolGraphResult,
};
use serde_json::{Value, json};

use super::{McpTool, ToolDescriptor, ToolError, ToolResult};

use crate::mcp::context::McpContext;

pub(super) struct GraphTool;

impl GraphTool {
	pub(super) const NAME: &'static str = "code_moniker_graph";

	const DESCRIPTION: &'static str = concat!(
		"When to use: understand a symbol or file through its call-graph ",
		"neighborhood - who calls it, what it calls outside itself, and its ",
		"internal structure - before editing or reviewing it.\n",
		"\n",
		"Ego-centric unit neighborhood from code-moniker.\n",
		"The focus (symbol URI or workspace-relative file path) defines a unit ",
		"boundary; resolved references partition into internal edges, callers ",
		"(outside-in) and callees (inside-out), aggregated per neighbor with ",
		"relation kinds and call counts. Unresolved references are counted, ",
		"never dropped."
	);

	const DEFAULT_MAX_ITEMS: usize = 40;

	fn input_schema() -> Value {
		json!({
			"type": "object",
			"properties": {
				"focus": {
					"type": "string",
					"description": "Symbol URI (code+moniker://...) or workspace-relative file path."
				},
				"max_items": {
					"type": "integer",
					"minimum": 1,
					"maximum": 500,
					"description": "Bound for listed neighbors and members. Defaults 40; truncation is reported."
				}
			},
			"required": ["focus"],
			"additionalProperties": false
		})
	}
}

impl McpTool for GraphTool {
	fn descriptor(&self) -> ToolDescriptor {
		ToolDescriptor {
			name: Self::NAME,
			description: Self::DESCRIPTION,
			input_schema: Self::input_schema(),
		}
	}

	fn call(&self, context: &McpContext, arguments: &Value) -> Result<ToolResult, ToolError> {
		let Some(focus) = arguments.get("focus").and_then(Value::as_str) else {
			return Err(ToolError::failed(anyhow::anyhow!("focus is required")));
		};
		let max_items = arguments
			.get("max_items")
			.and_then(Value::as_u64)
			.map(|value| value as usize)
			.unwrap_or(Self::DEFAULT_MAX_ITEMS);
		let response = context
			.query(QueryRequest {
				query: Query::SymbolGraph(SymbolGraphQuery {
					workspace: None,
					focus: focus.to_string(),
				}),
				consistency: code_moniker_query::Consistency::RefreshIfStale,
				page: Page::default(),
			})
			.map_err(ToolError::failed)?;
		let QueryResult::SymbolGraph(result) = response.result else {
			return Err(ToolError::failed(anyhow::anyhow!(
				"unexpected symbol graph response"
			)));
		};
		Ok(ToolResult {
			text: render_graph(&result, max_items),
			is_error: false,
		})
	}
}

fn render_graph(result: &SymbolGraphResult, max_items: usize) -> String {
	let mut out = String::new();
	match &result.focus {
		SymbolGraphFocus::Symbol { symbol } => {
			let _ = writeln!(
				out,
				"focus: {} {} ({})",
				symbol.kind, symbol.name, symbol.file
			);
		}
		SymbolGraphFocus::File { path } => {
			let _ = writeln!(out, "focus: file {path}");
		}
	}
	let _ = writeln!(
		out,
		"members: {} internal edges: {}",
		result.members.len(),
		result.internal_edges.len()
	);
	let _ = writeln!(
		out,
		"unlinked refs: external {} · manifest-blocked {} · unresolved {}",
		result.unlinked.external, result.unlinked.manifest_blocked, result.unlinked.unresolved
	);
	if !result.unlinked.unresolved_reasons.is_empty() {
		let reasons = result
			.unlinked
			.unresolved_reasons
			.iter()
			.map(|(reason, count)| format!("{reason} {count}"))
			.collect::<Vec<_>>()
			.join(" · ");
		let _ = writeln!(out, "unresolved by reason: {reasons}");
	}
	render_neighbors(&mut out, "callers", &result.callers, max_items);
	render_neighbors(&mut out, "callees", &result.callees, max_items);
	out
}

fn render_neighbors(
	out: &mut String,
	label: &str,
	neighbors: &[SymbolGraphNeighbor],
	max_items: usize,
) {
	let _ = writeln!(out, "{label}: {}", neighbors.len());
	for neighbor in neighbors.iter().take(max_items) {
		let _ = writeln!(
			out,
			"- {} {} ({}) x{} [{}]",
			neighbor.symbol.kind,
			neighbor.symbol.name,
			neighbor.symbol.file,
			neighbor.count,
			neighbor.kinds.join(",")
		);
	}
	if neighbors.len() > max_items {
		let _ = writeln!(out, "- truncated: +{}", neighbors.len() - max_items);
	}
}