xtask/rmcp_release_monitor/
schema.rs1use anyhow::{Context, Result};
8use serde::Deserialize;
9use sha2::{Digest, Sha256};
10use std::collections::BTreeSet;
11use std::path::PathBuf;
12
13use super::diagnostics::json_parse_context;
14use super::impact::{RepoImpact, append_impact_section, collect_identifiers, scan_repo_impacts};
15use super::short_sha;
16
17#[derive(Debug)]
18pub(super) struct SchemaMonitorInput {
19 pub(super) baseline: String,
20 pub(super) upstream: String,
21 pub(super) commits_json: Option<String>,
22 pub(super) url: String,
23 pub(super) repo_root: PathBuf,
24}
25
26#[derive(Debug)]
27pub(super) struct SchemaReport {
28 pub(super) drift: bool,
29 pub(super) baseline_hash: String,
30 pub(super) upstream_hash: String,
31 url: String,
32 diff: String,
33 commits: Vec<SchemaCommit>,
34 impacts: Vec<RepoImpact>,
35}
36
37#[derive(Debug, Deserialize)]
38struct SchemaCommit {
39 sha: String,
40 html_url: String,
41 commit: SchemaCommitDetails,
42}
43
44#[derive(Debug, Deserialize)]
45struct SchemaCommitDetails {
46 message: String,
47 author: SchemaCommitAuthor,
48}
49
50#[derive(Debug, Deserialize)]
51struct SchemaCommitAuthor {
52 date: String,
53}
54
55pub(super) fn build_schema_report(input: &SchemaMonitorInput) -> Result<SchemaReport> {
56 let baseline_hash = sha256_hex(input.baseline.as_bytes());
57 let upstream_hash = sha256_hex(input.upstream.as_bytes());
58 let drift = baseline_hash != upstream_hash;
59 let commits = input
60 .commits_json
61 .as_deref()
62 .map(|json| {
63 serde_json::from_str(json).with_context(|| {
64 json_parse_context("MCP schema commit JSON (--schema-commits-json)", json)
65 })
66 })
67 .transpose()?
68 .unwrap_or_default();
69 let changed_terms = if drift {
70 changed_terms_from_text_diff(&input.baseline, &input.upstream)
71 } else {
72 BTreeSet::new()
73 };
74 Ok(SchemaReport {
75 drift,
76 baseline_hash,
77 upstream_hash,
78 url: input.url.clone(),
79 diff: if drift {
80 simple_unified_diff(
81 "docs/references/mcp/schema/2025-11-25/schema.ts",
82 &input.url,
83 &input.baseline,
84 &input.upstream,
85 30_000,
86 )
87 } else {
88 String::new()
89 },
90 commits,
91 impacts: if drift {
92 scan_repo_impacts(&input.repo_root, &changed_terms)?
93 } else {
94 Vec::new()
95 },
96 })
97}
98
99pub(super) fn append_schema_section(body: &mut String, report: &SchemaReport) {
100 body.push_str("## MCP Schema Watch\n\n");
101 body.push_str(&format!(
102 "- Upstream schema: [{}]({})\n",
103 report.url, report.url
104 ));
105 body.push_str(&format!("- Baseline SHA-256: `{}`\n", report.baseline_hash));
106 body.push_str(&format!("- Upstream SHA-256: `{}`\n", report.upstream_hash));
107 body.push_str(&format!("- Drift: `{}`\n\n", report.drift));
108 if !report.commits.is_empty() {
109 body.push_str("### Recent schema commits\n\n");
110 for commit in report.commits.iter().take(5) {
111 let summary = commit.commit.message.lines().next().unwrap_or("").trim();
112 body.push_str(&format!(
113 "- [`{}`]({}) `{}` {}\n",
114 short_sha(&commit.sha),
115 commit.html_url,
116 commit.commit.author.date,
117 summary
118 ));
119 }
120 body.push('\n');
121 }
122 append_impact_section(
123 body,
124 "Potential schema impact in this repo",
125 &report.impacts,
126 );
127 if report.drift {
128 body.push_str("<details><summary>MCP schema diff</summary>\n\n");
129 body.push_str("```diff\n");
130 body.push_str(&report.diff);
131 if !report.diff.ends_with('\n') {
132 body.push('\n');
133 }
134 body.push_str("```\n\n</details>\n\n");
135 }
136}
137
138fn sha256_hex(bytes: &[u8]) -> String {
139 Sha256::digest(bytes)
140 .iter()
141 .map(|byte| format!("{byte:02x}"))
142 .collect()
143}
144
145fn simple_unified_diff(
146 old_label: &str,
147 new_label: &str,
148 old: &str,
149 new: &str,
150 max_bytes: usize,
151) -> String {
152 let mut diff = String::new();
153 diff.push_str(&format!("--- {old_label}\n"));
154 diff.push_str(&format!("+++ {new_label}\n"));
155 let old_lines = old.lines().collect::<Vec<_>>();
156 let new_lines = new.lines().collect::<Vec<_>>();
157 let max_len = old_lines.len().max(new_lines.len());
158 for index in 0..max_len {
159 match (old_lines.get(index), new_lines.get(index)) {
160 (Some(left), Some(right)) if left == right => {}
161 (Some(left), Some(right)) => {
162 diff.push_str(&format!("@@ line {} @@\n", index + 1));
163 diff.push_str(&format!("-{left}\n"));
164 diff.push_str(&format!("+{right}\n"));
165 }
166 (Some(left), None) => {
167 diff.push_str(&format!("@@ line {} @@\n", index + 1));
168 diff.push_str(&format!("-{left}\n"));
169 }
170 (None, Some(right)) => {
171 diff.push_str(&format!("@@ line {} @@\n", index + 1));
172 diff.push_str(&format!("+{right}\n"));
173 }
174 (None, None) => {}
175 }
176 if diff.len() > max_bytes {
177 diff.truncate(max_bytes);
178 diff.push_str("\n... diff truncated ...\n");
179 break;
180 }
181 }
182 diff
183}
184
185fn changed_terms_from_text_diff(old: &str, new: &str) -> BTreeSet<String> {
186 let mut terms = BTreeSet::new();
187 let old_lines = old.lines().collect::<Vec<_>>();
188 let new_lines = new.lines().collect::<Vec<_>>();
189 let max_len = old_lines.len().max(new_lines.len());
190 for index in 0..max_len {
191 match (old_lines.get(index), new_lines.get(index)) {
192 (Some(left), Some(right)) if left == right => {}
193 (Some(left), Some(right)) => {
194 collect_identifiers(left, &mut terms);
195 collect_identifiers(right, &mut terms);
196 }
197 (Some(left), None) => collect_identifiers(left, &mut terms),
198 (None, Some(right)) => collect_identifiers(right, &mut terms),
199 (None, None) => {}
200 }
201 }
202 terms
203}