1use anyhow::{anyhow, bail, Context, Result};
2use serde_json::Value;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use walkdir::WalkDir;
7
8const NO_MCP_REF: &str = "marketplace-no-mcp";
9
10pub fn apply_cmd() -> Result<()> {
11 let root = std::env::current_dir().context("failed to read current directory")?;
12 let changed = apply(&root)?;
13 if changed.is_empty() {
14 println!("no-MCP marketplace rules already applied");
15 } else {
16 for change in changed {
17 println!("{change}");
18 }
19 }
20 Ok(())
21}
22
23pub fn check_cmd(args: &[String]) -> Result<()> {
24 let mut check_current = false;
25 let mut compare_ref = false;
26 let mut skip_if_missing_refs = false;
27 let mut main_ref = "origin/main".to_owned();
28 let mut no_mcp_ref = format!("origin/{NO_MCP_REF}");
29
30 let mut index = 0usize;
31 while index < args.len() {
32 match args[index].as_str() {
33 "--check-current" => check_current = true,
34 "--compare-ref" => compare_ref = true,
35 "--skip-if-missing-refs" => skip_if_missing_refs = true,
36 "--main-ref" => {
37 index += 1;
38 main_ref = args
39 .get(index)
40 .context("--main-ref requires a value")?
41 .to_owned();
42 }
43 "--no-mcp-ref" => {
44 index += 1;
45 no_mcp_ref = args
46 .get(index)
47 .context("--no-mcp-ref requires a value")?
48 .to_owned();
49 }
50 "--help" | "-h" => {
51 bail!("Usage: cargo xtask check-no-mcp-drift [--check-current|--compare-ref] [--skip-if-missing-refs] [--main-ref REF] [--no-mcp-ref REF]");
52 }
53 unknown => bail!("unknown check-no-mcp-drift option: {unknown}"),
54 }
55 index += 1;
56 }
57
58 let root = std::env::current_dir().context("failed to read current directory")?;
59 if compare_ref {
60 return compare_refs(&root, &main_ref, &no_mcp_ref, skip_if_missing_refs);
61 }
62 if check_current || current_branch(&root)? == NO_MCP_REF {
63 return check_current_invariants(&root);
64 }
65 println!("not on marketplace-no-mcp; no-MCP current-branch check skipped");
66 Ok(())
67}
68
69fn apply(root: &Path) -> Result<Vec<String>> {
70 let mut changed = Vec::new();
71
72 for path in mcp_config_files(root) {
73 fs::remove_file(&path)
74 .with_context(|| format!("failed to remove {}", display_path(root, &path)))?;
75 changed.push(format!("removed MCP config: {}", display_path(root, &path)));
76 }
77
78 for path in manifest_files(root) {
79 let Ok(text) = fs::read_to_string(&path) else {
80 continue;
81 };
82 let Ok(mut data) = serde_json::from_str::<Value>(&text) else {
83 continue;
84 };
85 let Some(object) = data.as_object_mut() else {
86 continue;
87 };
88 if object.remove("mcpServers").is_some() {
89 write_json(&path, &data)
90 .with_context(|| format!("failed to write {}", display_path(root, &path)))?;
91 changed.push(format!(
92 "stripped manifest MCP servers: {}",
93 display_path(root, &path)
94 ));
95 }
96 }
97
98 Ok(changed)
99}
100
101fn check_current_invariants(root: &Path) -> Result<()> {
102 let mut errors = Vec::new();
103
104 for path in mcp_config_files(root) {
105 errors.push(format!(
106 "no-MCP branch must not contain {}",
107 display_path(root, &path)
108 ));
109 }
110
111 for path in manifest_files(root) {
112 let Ok(text) = fs::read_to_string(&path) else {
113 continue;
114 };
115 let Ok(data) = serde_json::from_str::<Value>(&text) else {
116 continue;
117 };
118 if data
119 .as_object()
120 .is_some_and(|object| object.contains_key("mcpServers"))
121 {
122 errors.push(format!(
123 "no-MCP branch manifest must not declare mcpServers: {}",
124 display_path(root, &path)
125 ));
126 }
127 }
128
129 if errors.is_empty() {
130 println!("no-MCP invariant check passed");
131 Ok(())
132 } else {
133 eprintln!("no-MCP invariant check failed:");
134 for error in &errors {
135 eprintln!("- {error}");
136 }
137 bail!("no-MCP invariant check failed")
138 }
139}
140
141fn compare_refs(
142 root: &Path,
143 main_ref: &str,
144 no_mcp_ref: &str,
145 skip_if_missing_refs: bool,
146) -> Result<()> {
147 let fetch = run_git(
148 root,
149 &[
150 "fetch",
151 "origin",
152 &fetch_ref_name(main_ref),
153 &fetch_ref_name(no_mcp_ref),
154 ],
155 );
156 if let Err(error) = fetch {
157 if skip_if_missing_refs {
158 eprintln!("no-MCP drift compare skipped because refs could not be fetched:\n{error:#}");
159 return Ok(());
160 }
161 return Err(error);
162 }
163
164 let temp_parent = std::env::temp_dir().join(format!(
165 "soma-no-mcp-drift.{}.{}",
166 std::process::id(),
167 nanos_since_epoch()
168 ));
169 let expected = temp_parent.join("expected");
170 let actual = temp_parent.join("actual");
171 fs::create_dir_all(&temp_parent)
172 .with_context(|| format!("failed to create {}", temp_parent.display()))?;
173
174 let result = (|| {
175 run_git(
176 root,
177 &[
178 "worktree",
179 "add",
180 "--detach",
181 path_str(&expected)?,
182 main_ref,
183 ],
184 )?;
185 run_git(
186 root,
187 &[
188 "worktree",
189 "add",
190 "--detach",
191 path_str(&actual)?,
192 no_mcp_ref,
193 ],
194 )?;
195
196 apply(&expected)?;
197 check_current_invariants(&expected)?;
198 check_current_invariants(&actual)?;
199
200 run_git(&expected, &["add", "-A"])?;
201 let expected_tree = run_git_output(&expected, &["write-tree"])?;
202 let actual_tree = run_git_output(&actual, &["rev-parse", "HEAD^{tree}"])?;
203 if expected_tree.trim() != actual_tree.trim() {
204 let diff = run_git_output(
205 &expected,
206 &["diff", "--stat", actual_tree.trim(), expected_tree.trim()],
207 )
208 .unwrap_or_else(|error| format!("failed to render diff stat: {error:#}"));
209 eprintln!("{no_mcp_ref} does not match {main_ref} plus no-MCP transform:");
210 eprintln!("{diff}");
211 bail!("no-MCP drift compare failed");
212 }
213
214 println!("no-MCP drift compare passed");
215 println!("- main ref: {main_ref}");
216 println!("- no-MCP ref: {no_mcp_ref}");
217 println!("- tree: {}", actual_tree.trim());
218 Ok(())
219 })();
220
221 for path in [&expected, &actual] {
222 if path.exists() {
223 let _ = run_git(
224 root,
225 &[
226 "worktree",
227 "remove",
228 "--force",
229 path_str(path).unwrap_or(""),
230 ],
231 );
232 }
233 }
234 let _ = fs::remove_dir_all(&temp_parent);
235 result
236}
237
238fn mcp_config_files(root: &Path) -> Vec<PathBuf> {
239 WalkDir::new(root)
240 .into_iter()
241 .filter_entry(|entry| !is_ignored(entry.path()))
242 .filter_map(Result::ok)
243 .filter(|entry| entry.file_type().is_file())
244 .filter_map(|entry| {
245 let name = entry.file_name().to_string_lossy();
246 if name == ".mcp.json" || name == "mcp.json" {
247 Some(entry.path().to_path_buf())
248 } else {
249 None
250 }
251 })
252 .collect()
253}
254
255fn manifest_files(root: &Path) -> Vec<PathBuf> {
256 WalkDir::new(root)
257 .into_iter()
258 .filter_entry(|entry| !is_ignored(entry.path()))
259 .filter_map(Result::ok)
260 .filter(|entry| entry.file_type().is_file())
261 .filter_map(|entry| {
262 let path = entry.path();
263 let name = path.file_name()?.to_string_lossy();
264 let parent = path.parent()?.file_name()?.to_string_lossy();
265 if name == "gemini-extension.json"
266 || (name == "plugin.json"
267 && (parent == ".claude-plugin" || parent == ".codex-plugin"))
268 {
269 Some(path.to_path_buf())
270 } else {
271 None
272 }
273 })
274 .collect()
275}
276
277fn is_ignored(path: &Path) -> bool {
278 path.file_name()
279 .and_then(|name| name.to_str())
280 .is_some_and(|name| {
281 matches!(
282 name,
283 ".git" | "target" | "node_modules" | ".next" | "dist" | ".cache"
284 )
285 })
286}
287
288fn write_json(path: &Path, data: &Value) -> Result<()> {
289 let text = serde_json::to_string_pretty(data)?;
290 fs::write(path, format!("{text}\n"))?;
291 Ok(())
292}
293
294fn current_branch(root: &Path) -> Result<String> {
295 Ok(run_git_output(root, &["branch", "--show-current"])?
296 .trim()
297 .to_owned())
298}
299
300fn fetch_ref_name(ref_name: &str) -> String {
301 ref_name
302 .strip_prefix("origin/")
303 .unwrap_or(ref_name)
304 .to_owned()
305}
306
307fn run_git(root: &Path, args: &[&str]) -> Result<()> {
308 let output = Command::new("git")
309 .args(args)
310 .current_dir(root)
311 .output()
312 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
313 if output.status.success() {
314 Ok(())
315 } else {
316 Err(command_error("git", args, output))
317 }
318}
319
320fn run_git_output(root: &Path, args: &[&str]) -> Result<String> {
321 let output = Command::new("git")
322 .args(args)
323 .current_dir(root)
324 .output()
325 .with_context(|| format!("failed to run git {}", args.join(" ")))?;
326 if output.status.success() {
327 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
328 } else {
329 Err(command_error("git", args, output))
330 }
331}
332
333fn command_error(command: &str, args: &[&str], output: std::process::Output) -> anyhow::Error {
334 anyhow!(
335 "`{} {}` failed with exit {}:\n{}{}",
336 command,
337 args.join(" "),
338 output.status,
339 String::from_utf8_lossy(&output.stdout),
340 String::from_utf8_lossy(&output.stderr),
341 )
342}
343
344fn path_str(path: &Path) -> Result<&str> {
345 path.to_str()
346 .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display()))
347}
348
349fn display_path(root: &Path, path: &Path) -> String {
350 path.strip_prefix(root)
351 .unwrap_or(path)
352 .display()
353 .to_string()
354}
355
356fn nanos_since_epoch() -> u128 {
357 std::time::SystemTime::now()
358 .duration_since(std::time::UNIX_EPOCH)
359 .map(|duration| duration.as_nanos())
360 .unwrap_or(0)
361}