mcp_sync/commands/
diff.rs

1//! `mcp-sync diff` command - show differences between canon and target configs.
2
3use crate::{all_targets, read_canon_auto, SyncOptions};
4use anyhow::Result;
5use similar::{ChangeTag, TextDiff};
6use std::fs;
7use std::path::Path;
8use tracing::info;
9
10/// ANSI color codes.
11const RED: &str = "\x1b[31m";
12const GREEN: &str = "\x1b[32m";
13const CYAN: &str = "\x1b[36m";
14const RESET: &str = "\x1b[0m";
15const BOLD: &str = "\x1b[1m";
16
17/// Prints a colored diff.
18fn print_diff(old: &str, new: &str, label: &str) {
19    let diff = TextDiff::from_lines(old, new);
20    
21    let mut has_changes = false;
22    for change in diff.iter_all_changes() {
23        if change.tag() != ChangeTag::Equal {
24            has_changes = true;
25            break;
26        }
27    }
28    
29    if !has_changes {
30        println!("{}{}{} - no changes", CYAN, label, RESET);
31        return;
32    }
33    
34    println!("\n{}{}{}:", BOLD, label, RESET);
35    
36    for change in diff.iter_all_changes() {
37        let (sign, color) = match change.tag() {
38            ChangeTag::Delete => ("-", RED),
39            ChangeTag::Insert => ("+", GREEN),
40            ChangeTag::Equal => (" ", ""),
41        };
42        print!("{}{}{}{}", color, sign, change, RESET);
43    }
44}
45
46/// Generates target config content without writing to disk.
47fn generate_target_content(
48    target: &dyn crate::Target,
49    canon: &crate::Canon,
50) -> Result<String> {
51    use tempfile::TempDir;
52    
53    let temp = TempDir::new()?;
54    let temp_path = temp.path().join("temp_config");
55    
56    let opts = SyncOptions::new(false, false, false);
57    target.sync(&temp_path, canon, &opts)?;
58    
59    if temp_path.exists() {
60        Ok(fs::read_to_string(&temp_path)?)
61    } else {
62        Ok(String::new())
63    }
64}
65
66/// Runs the diff command.
67pub fn run(canon_path: &str, scope: &str, project_root: &Path) -> Result<()> {
68    let canon = read_canon_auto(canon_path)?;
69    
70    info!("Comparing {} servers against targets", canon.servers.len());
71    
72    let targets = all_targets();
73    let do_global = scope == "global" || scope == "both";
74    let do_project = scope == "project" || scope == "both";
75    
76    for target in &targets {
77        if do_global
78            && let Ok(global_path) = target.global_path() {
79            let current = if global_path.exists() {
80                fs::read_to_string(&global_path).unwrap_or_default()
81            } else {
82                String::new()
83            };
84            
85            let expected = generate_target_content(target.as_ref(), &canon)
86                .unwrap_or_default();
87            
88            print_diff(
89                &current,
90                &expected,
91                &format!("{} (global: {})", target.name(), global_path.display()),
92            );
93        }
94        
95        if do_project {
96            let project_path = target.project_path(project_root);
97            let current = if project_path.exists() {
98                fs::read_to_string(&project_path).unwrap_or_default()
99            } else {
100                String::new()
101            };
102            
103            let expected = generate_target_content(target.as_ref(), &canon)
104                .unwrap_or_default();
105            
106            print_diff(
107                &current,
108                &expected,
109                &format!("{} (project: {})", target.name(), project_path.display()),
110            );
111        }
112    }
113    
114    Ok(())
115}