Skip to main content

xtask/
no_mcp.rs

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}