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::{
	ChangeReviewQuery, ChangeReviewResult, Page, Query, QueryRequest, QueryResult,
};
use serde_json::{Value, json};

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

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

pub(super) struct DiffTool;

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

	const DESCRIPTION: &'static str = concat!(
		"When to use: read the current git changes of the workspace as symbol-level facts ",
		"instead of line hunks - moved or renamed symbols, modified bodies, retargeted ",
		"imports and call sites, and residual (unattributed) edits.\n",
		"\n",
		"Semantic change review from code-moniker (scope HEAD..worktree).\n",
		"Facts only: kinds added/removed/body-modified/signature-changed/renamed/moved/",
		"attribute-changed with certain/candidate confidence, per-file dispositions and ",
		"hunk coverage. No importance judgment is applied."
	);

	const DEFAULT_MAX_ITEMS: usize = 50;

	fn input_schema() -> Value {
		json!({
			"type": "object",
			"properties": {
				"refs": {
					"type": "boolean",
					"description": "List individual reference facts instead of the collapsed count. Defaults false."
				},
				"max_items": {
					"type": "integer",
					"minimum": 1,
					"maximum": 500,
					"description": "Bound for listed symbol and reference facts. Defaults 50; truncation is reported."
				}
			},
			"additionalProperties": false
		})
	}
}

impl McpTool for DiffTool {
	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 detail_refs = arguments
			.get("refs")
			.and_then(Value::as_bool)
			.unwrap_or(false);
		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::ChangeReview(ChangeReviewQuery { workspace: None }),
				consistency: code_moniker_query::Consistency::RefreshIfStale,
				page: Page::default(),
			})
			.map_err(ToolError::failed)?;
		let QueryResult::ChangeReview(result) = response.result else {
			return Err(ToolError::failed(anyhow::anyhow!(
				"unexpected change review response"
			)));
		};
		Ok(ToolResult {
			text: render_review(&result, detail_refs, max_items),
			is_error: false,
		})
	}
}

fn render_review(result: &ChangeReviewResult, detail_refs: bool, max_items: usize) -> String {
	let mut out = String::new();
	let _ = writeln!(out, "scope: {}", result.scope);
	let _ = writeln!(
		out,
		"summary: files {} ({} analyzable) symbols {} refs {} ({} retargeted) residual {}",
		result.summary.files,
		result.summary.analyzable_files,
		result.summary.symbol_changes,
		result.summary.ref_changes,
		result.summary.retargeted_refs,
		result.summary.residual_files
	);
	for file in &result.files {
		let _ = writeln!(out, "{}", file_line(file));
	}
	render_symbols(&mut out, result, max_items);
	render_refs(&mut out, result, detail_refs, max_items);
	for diagnostic in &result.diagnostics {
		let _ = writeln!(out, "diagnostic: {diagnostic}");
	}
	out
}

fn file_line(file: &code_moniker_query::ChangeReviewFile) -> String {
	let path = match (&file.old_path, &file.new_path) {
		(Some(old), Some(new)) if old != new => format!("{old} -> {new}"),
		(_, Some(new)) => new.clone(),
		(Some(old), None) => old.clone(),
		(None, None) => "<unknown>".to_string(),
	};
	format!(
		"- {path} {}{}{}",
		file.disposition,
		if file.analyzable {
			""
		} else {
			" (not analyzable)"
		},
		if file.coverage_explained {
			""
		} else {
			" [residual]"
		}
	)
}

fn render_symbols(out: &mut String, result: &ChangeReviewResult, max_items: usize) {
	for change in result.symbol_changes.iter().take(max_items) {
		let Some(side) = change.new.as_ref().or(change.old.as_ref()) else {
			continue;
		};
		let _ = writeln!(
			out,
			"  {} {} {} [{}]",
			change.kind, side.kind, side.name, change.confidence
		);
	}
	if result.symbol_changes.len() > max_items {
		let _ = writeln!(
			out,
			"  truncated: +{} symbol fact(s)",
			result.symbol_changes.len() - max_items
		);
	}
}

fn render_refs(out: &mut String, result: &ChangeReviewResult, detail_refs: bool, max_items: usize) {
	if !detail_refs {
		return;
	}
	for change in result.ref_changes.iter().take(max_items) {
		let _ = writeln!(out, "  {} {} {}", change.kind, change.ref_kind, change.file);
	}
	if result.ref_changes.len() > max_items {
		let _ = writeln!(
			out,
			"  truncated: +{} ref fact(s)",
			result.ref_changes.len() - max_items
		);
	}
}