1pub mod handlers;
14
15use anyhow::{Context, Result};
16use chrono::Utc;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19use std::collections::HashMap;
20use std::path::Path;
21
22#[derive(Debug, Default, Serialize, Deserialize)]
25pub struct ClaudeSettings {
26 #[serde(rename = "mcpServers", skip_serializing_if = "Option::is_none")]
28 pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub hooks: Option<Value>,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub permissions: Option<Value>,
37
38 #[serde(flatten)]
40 pub other: HashMap<String, Value>,
41}
42
43#[derive(Debug, Default, Serialize, Deserialize)]
49pub struct McpConfig {
50 #[serde(rename = "mcpServers")]
52 pub mcp_servers: HashMap<String, McpServerConfig>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct McpServerConfig {
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub command: Option<String>,
64
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub args: Vec<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub env: Option<HashMap<String, Value>>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub r#type: Option<String>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub url: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub headers: Option<HashMap<String, Value>>,
84
85 #[serde(rename = "_agpm", skip_serializing_if = "Option::is_none")]
87 pub agpm_metadata: Option<AgpmMetadata>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct AgpmMetadata {
96 pub managed: bool,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub source: Option<String>,
102
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub version: Option<String>,
106
107 pub installed_at: String,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub dependency_name: Option<String>,
113}
114
115impl ClaudeSettings {
116 pub fn load_or_default(path: &Path) -> Result<Self> {
120 if path.exists() {
121 crate::utils::read_json_file(path).with_context(|| {
122 format!(
123 "Failed to parse settings file: {}\n\
124 The file may be malformed or contain invalid JSON.",
125 path.display()
126 )
127 })
128 } else {
129 Ok(Self::default())
130 }
131 }
132
133 pub fn save(&self, path: &Path) -> Result<()> {
137 if path.exists() {
139 let backup_path = crate::utils::generate_backup_path(path, "claude-code")?;
141
142 if let Some(backup_dir) = backup_path.parent() {
144 if !backup_dir.exists() {
145 std::fs::create_dir_all(backup_dir).with_context(|| {
146 format!("Failed to create directory: {}", backup_dir.display())
147 })?;
148 }
149 }
150
151 std::fs::copy(path, &backup_path).with_context(|| {
152 format!("Failed to create backup of settings at: {}", backup_path.display())
153 })?;
154 }
155
156 crate::utils::write_json_file(path, self, true)
158 .with_context(|| format!("Failed to write settings to: {}", path.display()))?;
159
160 Ok(())
161 }
162
163 pub fn update_mcp_servers(&mut self, mcp_servers_dir: &Path) -> Result<()> {
168 if !mcp_servers_dir.exists() {
169 return Ok(());
170 }
171
172 let mut agpm_servers = HashMap::new();
173
174 for entry in std::fs::read_dir(mcp_servers_dir).with_context(|| {
176 format!("Failed to read MCP servers directory: {}", mcp_servers_dir.display())
177 })? {
178 let entry = entry?;
179 let path = entry.path();
180
181 if path.extension().is_some_and(|ext| ext == "json") {
182 let server_config: McpServerConfig = crate::utils::read_json_file(&path)
183 .with_context(|| {
184 format!("Failed to parse MCP server file: {}", path.display())
185 })?;
186
187 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
189 agpm_servers.insert(name.to_string(), server_config);
190 }
191 }
192 }
193
194 if self.mcp_servers.is_none() {
196 self.mcp_servers = Some(HashMap::new());
197 }
198
199 if let Some(servers) = &mut self.mcp_servers {
201 servers
203 .retain(|_, config| config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed));
204
205 servers.extend(agpm_servers);
207 }
208
209 Ok(())
210 }
211}
212
213impl McpConfig {
214 pub fn load_or_default(path: &Path) -> Result<Self> {
219 if path.exists() {
220 crate::utils::read_json_file(path).with_context(|| {
222 format!(
223 "Failed to parse MCP configuration file: {}\n\
224 The file may be malformed or contain invalid JSON.",
225 path.display()
226 )
227 })
228 } else {
229 Ok(Self::default())
230 }
231 }
232
233 pub fn save(&self, path: &Path) -> Result<()> {
237 if path.exists() {
239 let backup_path = crate::utils::generate_backup_path(path, "claude-code")?;
241
242 if let Some(backup_dir) = backup_path.parent() {
244 if !backup_dir.exists() {
245 std::fs::create_dir_all(backup_dir).with_context(|| {
246 format!("Failed to create directory: {}", backup_dir.display())
247 })?;
248 }
249 }
250
251 std::fs::copy(path, &backup_path).with_context(|| {
252 format!(
253 "Failed to create backup of MCP configuration at: {}",
254 backup_path.display()
255 )
256 })?;
257 }
258
259 crate::utils::write_json_file(path, self, true)
261 .with_context(|| format!("Failed to write MCP configuration to: {}", path.display()))?;
262
263 Ok(())
264 }
265
266 pub fn update_managed_servers(
273 &mut self,
274 updates: HashMap<String, McpServerConfig>,
275 ) -> Result<()> {
276 let updating_names: std::collections::HashSet<_> = updates.keys().cloned().collect();
278
279 self.mcp_servers.retain(|name, config| {
281 config
285 .agpm_metadata
286 .as_ref()
287 .is_none_or(|meta| !meta.managed || updating_names.contains(name))
288 });
289
290 for (name, config) in updates {
292 self.mcp_servers.insert(name, config);
293 }
294
295 Ok(())
296 }
297
298 #[must_use]
303 pub fn check_conflicts(&self, new_servers: &HashMap<String, McpServerConfig>) -> Vec<String> {
304 let mut conflicts = Vec::new();
305
306 for name in new_servers.keys() {
307 if let Some(existing) = self.mcp_servers.get(name) {
308 if existing.agpm_metadata.is_none()
310 || !existing.agpm_metadata.as_ref().unwrap().managed
311 {
312 conflicts.push(name.clone());
313 }
314 }
315 }
316
317 conflicts
318 }
319
320 pub fn remove_all_managed(&mut self) {
324 self.mcp_servers
325 .retain(|_, config| config.agpm_metadata.as_ref().is_none_or(|meta| !meta.managed));
326 }
327
328 #[must_use]
330 pub fn get_managed_servers(&self) -> HashMap<String, &McpServerConfig> {
331 self.mcp_servers
332 .iter()
333 .filter(|(_, config)| config.agpm_metadata.as_ref().is_some_and(|meta| meta.managed))
334 .map(|(name, config)| (name.clone(), config))
335 .collect()
336 }
337}
338
339pub async fn merge_mcp_servers(
346 mcp_config_path: &Path,
347 agpm_servers: HashMap<String, McpServerConfig>,
348) -> Result<usize> {
349 if agpm_servers.is_empty() {
350 return Ok(0);
351 }
352
353 let mut mcp_config = McpConfig::load_or_default(mcp_config_path)?;
355
356 let conflicts = mcp_config.check_conflicts(&agpm_servers);
358 if !conflicts.is_empty() {
359 return Err(anyhow::anyhow!(
360 "The following MCP servers already exist and are not managed by AGPM: {}\n\
361 Please rename your servers or remove the existing ones from .mcp.json",
362 conflicts.join(", ")
363 ));
364 }
365
366 let mut changed_count = 0;
368 for (name, new_config) in &agpm_servers {
369 match mcp_config.mcp_servers.get(name) {
370 Some(existing_config) => {
371 let mut existing_without_time = existing_config.clone();
374 let mut new_without_time = new_config.clone();
375
376 if let Some(ref mut meta) = existing_without_time.agpm_metadata {
378 meta.installed_at = String::new();
379 }
380 if let Some(ref mut meta) = new_without_time.agpm_metadata {
381 meta.installed_at = String::new();
382 }
383
384 if existing_without_time != new_without_time {
385 changed_count += 1;
386 }
387 }
388 None => {
389 changed_count += 1;
391 }
392 }
393 }
394
395 mcp_config.update_managed_servers(agpm_servers)?;
397
398 mcp_config.save(mcp_config_path)?;
400
401 Ok(changed_count)
402}
403
404pub async fn configure_mcp_servers(project_root: &Path, mcp_servers_dir: &Path) -> Result<()> {
405 if !mcp_servers_dir.exists() {
406 return Ok(());
407 }
408
409 let mcp_config_path = project_root.join(".mcp.json");
410
411 let mut agpm_servers = HashMap::new();
413 let mut entries = tokio::fs::read_dir(mcp_servers_dir).await?;
414 while let Some(entry) = entries.next_entry().await? {
415 let path = entry.path();
416
417 if path.extension().is_some_and(|ext| ext == "json")
418 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
419 {
420 let config: McpServerConfig = crate::utils::read_json_file(&path)
422 .with_context(|| format!("Failed to parse MCP server file: {}", path.display()))?;
423
424 let mut config_with_metadata = config;
426 if config_with_metadata.agpm_metadata.is_none() {
427 config_with_metadata.agpm_metadata = Some(AgpmMetadata {
428 managed: true,
429 source: Some("agpm".to_string()),
430 version: None,
431 installed_at: Utc::now().to_rfc3339(),
432 dependency_name: Some(name.to_string()),
433 });
434 }
435
436 agpm_servers.insert(name.to_string(), config_with_metadata);
437 }
438 }
439
440 merge_mcp_servers(&mcp_config_path, agpm_servers).await?;
442 Ok(())
443}
444
445pub fn clean_mcp_servers(project_root: &Path) -> Result<()> {
447 let claude_dir = project_root.join(".claude");
448 let agpm_dir = claude_dir.join("agpm");
449 let mcp_servers_dir = agpm_dir.join("mcp-servers");
450 let mcp_config_path = project_root.join(".mcp.json");
451
452 let mut removed_count = 0;
454 if mcp_servers_dir.exists() {
455 for entry in std::fs::read_dir(&mcp_servers_dir)? {
456 let entry = entry?;
457 let path = entry.path();
458 if path.extension().is_some_and(|ext| ext == "json") {
459 std::fs::remove_file(&path).with_context(|| {
460 format!("Failed to remove MCP server file: {}", path.display())
461 })?;
462 removed_count += 1;
463 }
464 }
465 }
466
467 if mcp_config_path.exists() {
469 let mut mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
470 mcp_config.remove_all_managed();
471 mcp_config.save(&mcp_config_path)?;
472 }
473
474 if removed_count == 0 {
475 println!("No AGPM-managed MCP servers found");
476 } else {
477 println!("✓ Removed {removed_count} AGPM-managed MCP server(s)");
478 }
479
480 Ok(())
481}
482
483pub fn list_mcp_servers(project_root: &Path) -> Result<()> {
485 let mcp_config_path = project_root.join(".mcp.json");
486
487 if !mcp_config_path.exists() {
488 println!("No .mcp.json file found");
489 return Ok(());
490 }
491
492 let mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
493
494 if mcp_config.mcp_servers.is_empty() {
495 println!("No MCP servers configured");
496 return Ok(());
497 }
498
499 let servers = &mcp_config.mcp_servers;
500 println!("MCP Servers:");
501 println!("â•─────────────────────┬──────────┬───────────╮");
502 println!("│ Name │ Managed │ Version │");
503 println!("├─────────────────────┼──────────┼───────────┤");
504
505 for (name, server) in servers {
506 let (managed, version) = if let Some(meta) = &server.agpm_metadata {
507 if meta.managed {
508 ("✓ (agpm)", meta.version.as_deref().unwrap_or("-"))
509 } else {
510 ("✗", "-")
511 }
512 } else {
513 ("✗", "-")
514 };
515
516 println!("│ {name:<19} │ {managed:<8} │ {version:<9} │");
517 }
518
519 println!("╰─────────────────────┴──────────┴───────────╯");
520
521 Ok(())
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527 use serde_json::json;
528 use std::fs;
529 use tempfile::tempdir;
530
531 fn setup_project_root(temp_path: &std::path::Path) {
533 fs::write(temp_path.join("agpm.toml"), "[dependencies]\n").unwrap();
534 }
535
536 #[test]
537 fn test_claude_settings_load_save() {
538 let temp = tempdir().unwrap();
539 let settings_path = temp.path().join("settings.local.json");
540
541 let mut settings = ClaudeSettings::default();
542 let mut servers = HashMap::new();
543 servers.insert(
544 "test-server".to_string(),
545 McpServerConfig {
546 command: Some("node".to_string()),
547 args: vec!["server.js".to_string()],
548 env: None,
549 r#type: None,
550 url: None,
551 headers: None,
552 agpm_metadata: None,
553 },
554 );
555 settings.mcp_servers = Some(servers);
556
557 settings.save(&settings_path).unwrap();
558
559 let loaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
560 assert!(loaded.mcp_servers.is_some());
561 let servers = loaded.mcp_servers.unwrap();
562 assert_eq!(servers.len(), 1);
563 assert!(servers.contains_key("test-server"));
564 }
565
566 #[test]
567 fn test_claude_settings_load_nonexistent_file() {
568 let temp = tempdir().unwrap();
569 let settings_path = temp.path().join("nonexistent.json");
570
571 let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
572 assert!(settings.mcp_servers.is_none());
573 assert!(settings.permissions.is_none());
574 assert!(settings.other.is_empty());
575 }
576
577 #[test]
578 fn test_claude_settings_load_invalid_json() {
579 let temp = tempdir().unwrap();
580 let settings_path = temp.path().join("invalid.json");
581 fs::write(&settings_path, "invalid json {").unwrap();
582
583 let result = ClaudeSettings::load_or_default(&settings_path);
584 assert!(result.is_err());
585 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
586 }
587
588 #[test]
589 fn test_claude_settings_save_creates_backup() {
590 let temp = tempdir().unwrap();
591 setup_project_root(temp.path());
592
593 let settings_path = temp.path().join("settings.local.json");
594 let backup_path = temp
595 .path()
596 .join(".agpm")
597 .join("backups")
598 .join("claude-code")
599 .join("settings.local.json");
600
601 fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
603
604 let settings = ClaudeSettings::default();
605 settings.save(&settings_path).unwrap();
606
607 assert!(backup_path.exists());
609 let backup_content = fs::read_to_string(backup_path).unwrap();
610 assert_eq!(backup_content, r#"{"test": "value"}"#);
611 }
612
613 #[test]
614 fn test_claude_settings_update_mcp_servers_empty_dir() {
615 let temp = tempdir().unwrap();
616 let nonexistent_dir = temp.path().join("nonexistent");
617
618 let mut settings = ClaudeSettings::default();
619 settings.update_mcp_servers(&nonexistent_dir).unwrap();
621 }
622
623 #[test]
624 fn test_update_mcp_servers_from_directory() {
625 let temp = tempdir().unwrap();
626 let mcp_servers_dir = temp.path().join("mcp-servers");
627 std::fs::create_dir(&mcp_servers_dir).unwrap();
628
629 let server_config = McpServerConfig {
631 command: Some("managed".to_string()),
632 args: vec![],
633 env: None,
634 r#type: None,
635 url: None,
636 headers: None,
637 agpm_metadata: Some(AgpmMetadata {
638 managed: true,
639 source: Some("test".to_string()),
640 version: Some("v1.0.0".to_string()),
641 installed_at: "2024-01-01T00:00:00Z".to_string(),
642 dependency_name: Some("agpm-server".to_string()),
643 }),
644 };
645 let config_path = mcp_servers_dir.join("agpm-server.json");
646 let json = serde_json::to_string_pretty(&server_config).unwrap();
647 std::fs::write(&config_path, json).unwrap();
648
649 let mut settings = ClaudeSettings::default();
651 let mut servers = HashMap::new();
652 servers.insert(
653 "user-server".to_string(),
654 McpServerConfig {
655 command: Some("custom".to_string()),
656 args: vec![],
657 env: None,
658 r#type: None,
659 url: None,
660 headers: None,
661 agpm_metadata: None,
662 },
663 );
664 settings.mcp_servers = Some(servers);
665
666 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
668
669 assert!(settings.mcp_servers.is_some());
671 let servers = settings.mcp_servers.as_ref().unwrap();
672 assert!(servers.contains_key("user-server"));
673 assert!(servers.contains_key("agpm-server"));
674 assert_eq!(servers.len(), 2);
675 }
676
677 #[test]
678 fn test_update_mcp_servers_replaces_old_managed() {
679 let temp = tempdir().unwrap();
680 let mcp_servers_dir = temp.path().join("mcp-servers");
681 std::fs::create_dir(&mcp_servers_dir).unwrap();
682
683 let mut settings = ClaudeSettings::default();
685 let mut servers = HashMap::new();
686
687 servers.insert(
689 "user-server".to_string(),
690 McpServerConfig {
691 command: Some("user-command".to_string()),
692 args: vec![],
693 env: None,
694 r#type: None,
695 url: None,
696 headers: None,
697 agpm_metadata: None,
698 },
699 );
700
701 servers.insert(
703 "old-managed".to_string(),
704 McpServerConfig {
705 command: Some("old-command".to_string()),
706 args: vec![],
707 env: None,
708 r#type: None,
709 url: None,
710 headers: None,
711 agpm_metadata: Some(AgpmMetadata {
712 managed: true,
713 source: Some("old-source".to_string()),
714 version: Some("v0.1.0".to_string()),
715 installed_at: "2023-01-01T00:00:00Z".to_string(),
716 dependency_name: Some("old-managed".to_string()),
717 }),
718 },
719 );
720
721 settings.mcp_servers = Some(servers);
722
723 let server_config = McpServerConfig {
725 command: Some("new-managed".to_string()),
726 args: vec![],
727 env: None,
728 r#type: None,
729 url: None,
730 headers: None,
731 agpm_metadata: Some(AgpmMetadata {
732 managed: true,
733 source: Some("new-source".to_string()),
734 version: Some("v1.0.0".to_string()),
735 installed_at: "2024-01-01T00:00:00Z".to_string(),
736 dependency_name: Some("new-managed".to_string()),
737 }),
738 };
739 let config_path = mcp_servers_dir.join("new-managed.json");
740 let json = serde_json::to_string_pretty(&server_config).unwrap();
741 std::fs::write(&config_path, json).unwrap();
742
743 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
745
746 let servers = settings.mcp_servers.as_ref().unwrap();
747 assert!(servers.contains_key("user-server")); assert!(servers.contains_key("new-managed")); assert!(!servers.contains_key("old-managed")); assert_eq!(servers.len(), 2);
751 }
752
753 #[test]
754 fn test_update_mcp_servers_invalid_json_file() {
755 let temp = tempdir().unwrap();
756 let mcp_servers_dir = temp.path().join("mcp-servers");
757 std::fs::create_dir(&mcp_servers_dir).unwrap();
758
759 let invalid_path = mcp_servers_dir.join("invalid.json");
761 std::fs::write(&invalid_path, "invalid json {").unwrap();
762
763 let mut settings = ClaudeSettings::default();
764 let result = settings.update_mcp_servers(&mcp_servers_dir);
765 assert!(result.is_err());
766 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
767 }
768
769 #[test]
770 fn test_update_mcp_servers_ignores_non_json_files() {
771 let temp = tempdir().unwrap();
772 let mcp_servers_dir = temp.path().join("mcp-servers");
773 std::fs::create_dir(&mcp_servers_dir).unwrap();
774
775 let txt_path = mcp_servers_dir.join("readme.txt");
777 std::fs::write(&txt_path, "This is not a JSON file").unwrap();
778
779 let server_config = McpServerConfig {
781 command: Some("test".to_string()),
782 args: vec![],
783 env: None,
784 r#type: None,
785 url: None,
786 headers: None,
787 agpm_metadata: None,
788 };
789 let json_path = mcp_servers_dir.join("valid.json");
790 let json = serde_json::to_string_pretty(&server_config).unwrap();
791 std::fs::write(&json_path, json).unwrap();
792
793 let mut settings = ClaudeSettings::default();
794 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
795
796 let servers = settings.mcp_servers.as_ref().unwrap();
797 assert!(servers.contains_key("valid"));
798 assert_eq!(servers.len(), 1);
799 }
800
801 #[test]
802 fn test_settings_preserves_other_fields() {
803 let temp = tempdir().unwrap();
804 setup_project_root(temp.path());
805
806 let settings_path = temp.path().join("settings.local.json");
807
808 let json = r#"{
810 "permissions": {
811 "allow": ["Bash(ls)"],
812 "deny": []
813 },
814 "customField": "value",
815 "mcpServers": {
816 "test": {
817 "command": "node",
818 "args": []
819 }
820 }
821 }"#;
822 std::fs::write(&settings_path, json).unwrap();
823
824 let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
826 assert!(settings.permissions.is_some());
827 assert!(settings.mcp_servers.is_some());
828 assert!(settings.other.contains_key("customField"));
829
830 settings.save(&settings_path).unwrap();
831
832 let reloaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
834 assert!(reloaded.permissions.is_some());
835 assert!(reloaded.mcp_servers.is_some());
836 assert!(reloaded.other.contains_key("customField"));
837 }
838
839 #[test]
841 fn test_mcp_config_load_save() {
842 let temp = tempdir().unwrap();
843 let config_path = temp.path().join("mcp.json");
844
845 let mut config = McpConfig::default();
846 config.mcp_servers.insert(
847 "test-server".to_string(),
848 McpServerConfig {
849 command: Some("node".to_string()),
850 args: vec!["server.js".to_string()],
851 env: Some({
852 let mut env = HashMap::new();
853 env.insert("NODE_ENV".to_string(), json!("production"));
854 env
855 }),
856 r#type: None,
857 url: None,
858 headers: None,
859 agpm_metadata: None,
860 },
861 );
862
863 config.save(&config_path).unwrap();
864
865 let loaded = McpConfig::load_or_default(&config_path).unwrap();
866 assert!(loaded.mcp_servers.contains_key("test-server"));
867 let server = &loaded.mcp_servers["test-server"];
868 assert_eq!(server.command, Some("node".to_string()));
869 assert_eq!(server.args, vec!["server.js"]);
870 assert!(server.env.is_some());
871 }
872
873 #[test]
874 fn test_mcp_config_load_nonexistent() {
875 let temp = tempdir().unwrap();
876 let config_path = temp.path().join("nonexistent.json");
877
878 let config = McpConfig::load_or_default(&config_path).unwrap();
879 assert!(config.mcp_servers.is_empty());
880 }
881
882 #[test]
883 fn test_mcp_config_load_invalid_json() {
884 let temp = tempdir().unwrap();
885 let config_path = temp.path().join("invalid.json");
886 fs::write(&config_path, "invalid json {").unwrap();
887
888 let result = McpConfig::load_or_default(&config_path);
889 assert!(result.is_err());
890 }
891
892 #[test]
893 fn test_mcp_config_save_creates_backup() {
894 let temp = tempdir().unwrap();
895 setup_project_root(temp.path());
896
897 let config_path = temp.path().join("mcp.json");
898 let backup_path =
899 temp.path().join(".agpm").join("backups").join("claude-code").join("mcp.json");
900
901 fs::write(&config_path, r#"{"mcpServers": {"old": {"command": "old"}}}"#).unwrap();
903
904 let config = McpConfig::default();
905 config.save(&config_path).unwrap();
906
907 assert!(backup_path.exists());
909 let backup_content = fs::read_to_string(backup_path).unwrap();
910 assert!(backup_content.contains("old"));
911 }
912
913 #[test]
914 fn test_mcp_config_update_managed_servers() {
915 let mut config = McpConfig::default();
916
917 config.mcp_servers.insert(
919 "user-server".to_string(),
920 McpServerConfig {
921 command: Some("user-command".to_string()),
922 args: vec![],
923 env: None,
924 r#type: None,
925 url: None,
926 headers: None,
927 agpm_metadata: None,
928 },
929 );
930
931 config.mcp_servers.insert(
933 "old-managed".to_string(),
934 McpServerConfig {
935 command: Some("old-command".to_string()),
936 args: vec![],
937 env: None,
938 r#type: None,
939 url: None,
940 headers: None,
941 agpm_metadata: Some(AgpmMetadata {
942 managed: true,
943 source: None,
944 version: None,
945 installed_at: "old-time".to_string(),
946 dependency_name: None,
947 }),
948 },
949 );
950
951 let mut updates = HashMap::new();
953 updates.insert(
954 "new-managed".to_string(),
955 McpServerConfig {
956 command: Some("new-command".to_string()),
957 args: vec![],
958 env: None,
959 r#type: None,
960 url: None,
961 headers: None,
962 agpm_metadata: Some(AgpmMetadata {
963 managed: true,
964 source: None,
965 version: None,
966 installed_at: "new-time".to_string(),
967 dependency_name: None,
968 }),
969 },
970 );
971
972 config.update_managed_servers(updates).unwrap();
973
974 assert!(config.mcp_servers.contains_key("user-server"));
976 assert!(config.mcp_servers.contains_key("new-managed"));
977 assert!(!config.mcp_servers.contains_key("old-managed"));
978 assert_eq!(config.mcp_servers.len(), 2);
979 }
980
981 #[test]
982 fn test_mcp_config_update_managed_servers_preserves_updating_servers() {
983 let mut config = McpConfig::default();
984
985 config.mcp_servers.insert(
987 "updating-server".to_string(),
988 McpServerConfig {
989 command: Some("old-command".to_string()),
990 args: vec![],
991 env: None,
992 r#type: None,
993 url: None,
994 headers: None,
995 agpm_metadata: Some(AgpmMetadata {
996 managed: true,
997 source: None,
998 version: Some("v1.0.0".to_string()),
999 installed_at: "old-time".to_string(),
1000 dependency_name: None,
1001 }),
1002 },
1003 );
1004
1005 let mut updates = HashMap::new();
1007 updates.insert(
1008 "updating-server".to_string(),
1009 McpServerConfig {
1010 command: Some("new-command".to_string()),
1011 args: vec![],
1012 env: None,
1013 r#type: None,
1014 url: None,
1015 headers: None,
1016 agpm_metadata: Some(AgpmMetadata {
1017 managed: true,
1018 source: None,
1019 version: Some("v2.0.0".to_string()),
1020 installed_at: "new-time".to_string(),
1021 dependency_name: None,
1022 }),
1023 },
1024 );
1025
1026 config.update_managed_servers(updates).unwrap();
1027
1028 assert!(config.mcp_servers.contains_key("updating-server"));
1029 let server = &config.mcp_servers["updating-server"];
1030 assert_eq!(server.command, Some("new-command".to_string()));
1031 assert_eq!(server.agpm_metadata.as_ref().unwrap().version, Some("v2.0.0".to_string()));
1032 }
1033
1034 #[test]
1035 fn test_mcp_config_check_conflicts() {
1036 let mut config = McpConfig::default();
1037
1038 config.mcp_servers.insert(
1040 "user-server".to_string(),
1041 McpServerConfig {
1042 command: Some("user-command".to_string()),
1043 args: vec![],
1044 env: None,
1045 r#type: None,
1046 url: None,
1047 headers: None,
1048 agpm_metadata: None,
1049 },
1050 );
1051
1052 config.mcp_servers.insert(
1054 "managed-server".to_string(),
1055 McpServerConfig {
1056 command: Some("managed-command".to_string()),
1057 args: vec![],
1058 env: None,
1059 r#type: None,
1060 url: None,
1061 headers: None,
1062 agpm_metadata: Some(AgpmMetadata {
1063 managed: true,
1064 source: None,
1065 version: None,
1066 installed_at: "time".to_string(),
1067 dependency_name: None,
1068 }),
1069 },
1070 );
1071
1072 let mut new_servers = HashMap::new();
1073 new_servers.insert(
1074 "user-server".to_string(), McpServerConfig {
1076 command: Some("new-command".to_string()),
1077 args: vec![],
1078 env: None,
1079 r#type: None,
1080 url: None,
1081 headers: None,
1082 agpm_metadata: Some(AgpmMetadata {
1083 managed: true,
1084 source: None,
1085 version: None,
1086 installed_at: "time".to_string(),
1087 dependency_name: None,
1088 }),
1089 },
1090 );
1091 new_servers.insert(
1092 "managed-server".to_string(), McpServerConfig {
1094 command: Some("updated-command".to_string()),
1095 args: vec![],
1096 env: None,
1097 r#type: None,
1098 url: None,
1099 headers: None,
1100 agpm_metadata: Some(AgpmMetadata {
1101 managed: true,
1102 source: None,
1103 version: None,
1104 installed_at: "time".to_string(),
1105 dependency_name: None,
1106 }),
1107 },
1108 );
1109 new_servers.insert(
1110 "new-server".to_string(), McpServerConfig {
1112 command: Some("new-command".to_string()),
1113 args: vec![],
1114 env: None,
1115 r#type: None,
1116 url: None,
1117 headers: None,
1118 agpm_metadata: Some(AgpmMetadata {
1119 managed: true,
1120 source: None,
1121 version: None,
1122 installed_at: "time".to_string(),
1123 dependency_name: None,
1124 }),
1125 },
1126 );
1127
1128 let conflicts = config.check_conflicts(&new_servers);
1129 assert_eq!(conflicts, vec!["user-server"]);
1130 }
1131
1132 #[test]
1133 fn test_mcp_config_check_conflicts_unmanaged_metadata() {
1134 let mut config = McpConfig::default();
1135
1136 config.mcp_servers.insert(
1138 "unmanaged-server".to_string(),
1139 McpServerConfig {
1140 command: Some("user-command".to_string()),
1141 args: vec![],
1142 env: None,
1143 r#type: None,
1144 url: None,
1145 headers: None,
1146 agpm_metadata: Some(AgpmMetadata {
1147 managed: false,
1148 source: None,
1149 version: None,
1150 installed_at: "time".to_string(),
1151 dependency_name: None,
1152 }),
1153 },
1154 );
1155
1156 let mut new_servers = HashMap::new();
1157 new_servers.insert(
1158 "unmanaged-server".to_string(),
1159 McpServerConfig {
1160 command: Some("new-command".to_string()),
1161 args: vec![],
1162 env: None,
1163 r#type: None,
1164 url: None,
1165 headers: None,
1166 agpm_metadata: Some(AgpmMetadata {
1167 managed: true,
1168 source: None,
1169 version: None,
1170 installed_at: "time".to_string(),
1171 dependency_name: None,
1172 }),
1173 },
1174 );
1175
1176 let conflicts = config.check_conflicts(&new_servers);
1177 assert_eq!(conflicts, vec!["unmanaged-server"]);
1178 }
1179
1180 #[test]
1181 fn test_mcp_config_remove_all_managed() {
1182 let mut config = McpConfig::default();
1183
1184 config.mcp_servers.insert(
1186 "user-server".to_string(),
1187 McpServerConfig {
1188 command: Some("user-command".to_string()),
1189 args: vec![],
1190 env: None,
1191 r#type: None,
1192 url: None,
1193 headers: None,
1194 agpm_metadata: None,
1195 },
1196 );
1197
1198 config.mcp_servers.insert(
1199 "managed-server".to_string(),
1200 McpServerConfig {
1201 command: Some("managed-command".to_string()),
1202 args: vec![],
1203 env: None,
1204 r#type: None,
1205 url: None,
1206 headers: None,
1207 agpm_metadata: Some(AgpmMetadata {
1208 managed: true,
1209 source: None,
1210 version: None,
1211 installed_at: "time".to_string(),
1212 dependency_name: None,
1213 }),
1214 },
1215 );
1216
1217 config.mcp_servers.insert(
1218 "unmanaged-with-metadata".to_string(),
1219 McpServerConfig {
1220 command: Some("unmanaged-command".to_string()),
1221 args: vec![],
1222 env: None,
1223 r#type: None,
1224 url: None,
1225 headers: None,
1226 agpm_metadata: Some(AgpmMetadata {
1227 managed: false,
1228 source: None,
1229 version: None,
1230 installed_at: "time".to_string(),
1231 dependency_name: None,
1232 }),
1233 },
1234 );
1235
1236 config.remove_all_managed();
1237
1238 assert!(config.mcp_servers.contains_key("user-server"));
1239 assert!(config.mcp_servers.contains_key("unmanaged-with-metadata"));
1240 assert!(!config.mcp_servers.contains_key("managed-server"));
1241 assert_eq!(config.mcp_servers.len(), 2);
1242 }
1243
1244 #[test]
1245 fn test_mcp_config_get_managed_servers() {
1246 let mut config = McpConfig::default();
1247
1248 config.mcp_servers.insert(
1250 "user-server".to_string(),
1251 McpServerConfig {
1252 command: Some("user-command".to_string()),
1253 args: vec![],
1254 env: None,
1255 r#type: None,
1256 url: None,
1257 headers: None,
1258 agpm_metadata: None,
1259 },
1260 );
1261
1262 config.mcp_servers.insert(
1263 "managed-server1".to_string(),
1264 McpServerConfig {
1265 command: Some("managed-command1".to_string()),
1266 args: vec![],
1267 env: None,
1268 r#type: None,
1269 url: None,
1270 headers: None,
1271 agpm_metadata: Some(AgpmMetadata {
1272 managed: true,
1273 source: None,
1274 version: None,
1275 installed_at: "time".to_string(),
1276 dependency_name: None,
1277 }),
1278 },
1279 );
1280
1281 config.mcp_servers.insert(
1282 "managed-server2".to_string(),
1283 McpServerConfig {
1284 command: Some("managed-command2".to_string()),
1285 args: vec![],
1286 env: None,
1287 r#type: None,
1288 url: None,
1289 headers: None,
1290 agpm_metadata: Some(AgpmMetadata {
1291 managed: true,
1292 source: Some("source".to_string()),
1293 version: Some("v1.0.0".to_string()),
1294 installed_at: "time".to_string(),
1295 dependency_name: Some("dep".to_string()),
1296 }),
1297 },
1298 );
1299
1300 let managed = config.get_managed_servers();
1301 assert_eq!(managed.len(), 2);
1302 assert!(managed.contains_key("managed-server1"));
1303 assert!(managed.contains_key("managed-server2"));
1304 assert!(!managed.contains_key("user-server"));
1305 }
1306
1307 #[test]
1313 fn test_claude_settings_serialization() {
1314 let mut settings = ClaudeSettings {
1316 permissions: Some(json!({"allow": ["test"], "deny": []})),
1317 ..Default::default()
1318 };
1319
1320 let mut servers = HashMap::new();
1321 servers.insert(
1322 "test".to_string(),
1323 McpServerConfig {
1324 command: Some("test-cmd".to_string()),
1325 args: vec!["arg1".to_string()],
1326 env: Some({
1327 let mut env = HashMap::new();
1328 env.insert("VAR".to_string(), json!("value"));
1329 env
1330 }),
1331 r#type: None,
1332 url: None,
1333 headers: None,
1334 agpm_metadata: Some(AgpmMetadata {
1335 managed: true,
1336 source: Some("test-source".to_string()),
1337 version: Some("v1.0.0".to_string()),
1338 installed_at: "2024-01-01T00:00:00Z".to_string(),
1339 dependency_name: Some("test".to_string()),
1340 }),
1341 },
1342 );
1343 settings.mcp_servers = Some(servers);
1344
1345 settings.other.insert("custom".to_string(), json!("value"));
1346
1347 let json = serde_json::to_string(&settings).unwrap();
1349 let deserialized: ClaudeSettings = serde_json::from_str(&json).unwrap();
1350
1351 assert_eq!(deserialized.permissions, settings.permissions);
1352 assert_eq!(deserialized.mcp_servers.as_ref().unwrap().len(), 1);
1353 assert_eq!(deserialized.other.get("custom"), settings.other.get("custom"));
1354 }
1355
1356 #[test]
1357 fn test_mcp_config_serialization() {
1358 let mut config = McpConfig::default();
1359
1360 config.mcp_servers.insert(
1361 "test".to_string(),
1362 McpServerConfig {
1363 command: Some("test-cmd".to_string()),
1364 args: vec!["arg1".to_string(), "arg2".to_string()],
1365 env: Some({
1366 let mut env = HashMap::new();
1367 env.insert("TEST_VAR".to_string(), json!("test_value"));
1368 env
1369 }),
1370 r#type: None,
1371 url: None,
1372 headers: None,
1373 agpm_metadata: Some(AgpmMetadata {
1374 managed: true,
1375 source: Some("github.com/test/repo".to_string()),
1376 version: Some("v2.0.0".to_string()),
1377 installed_at: "2024-01-01T12:00:00Z".to_string(),
1378 dependency_name: Some("test-dep".to_string()),
1379 }),
1380 },
1381 );
1382
1383 let json = serde_json::to_string_pretty(&config).unwrap();
1385 let deserialized: McpConfig = serde_json::from_str(&json).unwrap();
1386
1387 assert_eq!(deserialized.mcp_servers.len(), 1);
1388 let server = &deserialized.mcp_servers["test"];
1389 assert_eq!(server.command, Some("test-cmd".to_string()));
1390 assert_eq!(server.args.len(), 2);
1391 assert!(server.env.is_some());
1392 assert!(server.agpm_metadata.is_some());
1393
1394 let metadata = server.agpm_metadata.as_ref().unwrap();
1395 assert!(metadata.managed);
1396 assert_eq!(metadata.source, Some("github.com/test/repo".to_string()));
1397 assert_eq!(metadata.version, Some("v2.0.0".to_string()));
1398 }
1399
1400 #[test]
1401 fn test_mcp_server_config_minimal_serialization() {
1402 let config = McpServerConfig {
1403 command: Some("minimal".to_string()),
1404 args: vec![],
1405 env: None,
1406 r#type: None,
1407 url: None,
1408 headers: None,
1409 agpm_metadata: None,
1410 };
1411
1412 let json = serde_json::to_string(&config).unwrap();
1413 let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
1414
1415 assert_eq!(deserialized.command, Some("minimal".to_string()));
1416 assert!(deserialized.args.is_empty());
1417 assert!(deserialized.env.is_none());
1418 assert!(deserialized.agpm_metadata.is_none());
1419
1420 assert!(!json.contains(r#""args":[]"#));
1422 }
1423
1424 #[test]
1425 fn test_agpm_metadata_serialization() {
1426 let metadata = AgpmMetadata {
1427 managed: true,
1428 source: Some("test-source".to_string()),
1429 version: None,
1430 installed_at: "2024-01-01T00:00:00Z".to_string(),
1431 dependency_name: Some("test-dep".to_string()),
1432 };
1433
1434 let json = serde_json::to_string(&metadata).unwrap();
1435 let deserialized: AgpmMetadata = serde_json::from_str(&json).unwrap();
1436
1437 assert!(deserialized.managed);
1438 assert_eq!(deserialized.source, Some("test-source".to_string()));
1439 assert_eq!(deserialized.version, None);
1440 assert_eq!(deserialized.installed_at, "2024-01-01T00:00:00Z");
1441 assert_eq!(deserialized.dependency_name, Some("test-dep".to_string()));
1442
1443 assert!(!json.contains(r#""version""#));
1445 }
1446
1447 #[test]
1448 fn test_clean_mcp_servers() {
1449 let temp = tempfile::TempDir::new().unwrap();
1450 setup_project_root(temp.path());
1451
1452 let project_root = temp.path();
1453 let claude_dir = project_root.join(".claude");
1454 let agpm_dir = claude_dir.join("agpm");
1455 let mcp_servers_dir = agpm_dir.join("mcp-servers");
1456 let settings_path = claude_dir.join("settings.local.json");
1457 let mcp_config_path = project_root.join(".mcp.json");
1458
1459 std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1461
1462 let server1_path = mcp_servers_dir.join("server1.json");
1464 let server2_path = mcp_servers_dir.join("server2.json");
1465 let server_config = McpServerConfig {
1466 command: Some("test".to_string()),
1467 args: vec![],
1468 env: None,
1469 r#type: None,
1470 url: None,
1471 headers: None,
1472 agpm_metadata: Some(AgpmMetadata {
1473 managed: true,
1474 source: Some("test-source".to_string()),
1475 version: Some("v1.0.0".to_string()),
1476 installed_at: "2024-01-01T00:00:00Z".to_string(),
1477 dependency_name: Some("test-server".to_string()),
1478 }),
1479 };
1480 crate::utils::write_json_file(&server1_path, &server_config, true).unwrap();
1481 crate::utils::write_json_file(&server2_path, &server_config, true).unwrap();
1482
1483 let mut settings = ClaudeSettings::default();
1485 let mut servers = HashMap::new();
1486
1487 servers.insert(
1489 "agpm-server".to_string(),
1490 McpServerConfig {
1491 command: Some("agpm-cmd".to_string()),
1492 args: vec![],
1493 env: None,
1494 r#type: None,
1495 url: None,
1496 headers: None,
1497 agpm_metadata: Some(AgpmMetadata {
1498 managed: true,
1499 source: Some("test".to_string()),
1500 version: Some("v1.0.0".to_string()),
1501 installed_at: "2024-01-01T00:00:00Z".to_string(),
1502 dependency_name: None,
1503 }),
1504 },
1505 );
1506
1507 servers.insert(
1509 "user-server".to_string(),
1510 McpServerConfig {
1511 command: Some("user-cmd".to_string()),
1512 args: vec![],
1513 env: None,
1514 r#type: None,
1515 url: None,
1516 headers: None,
1517 agpm_metadata: None,
1518 },
1519 );
1520
1521 settings.mcp_servers = Some(servers);
1522 settings.save(&settings_path).unwrap();
1523
1524 let mut mcp_config = McpConfig::default();
1526 mcp_config.mcp_servers.insert(
1527 "agpm-server".to_string(),
1528 McpServerConfig {
1529 command: Some("agpm-cmd".to_string()),
1530 args: vec![],
1531 env: None,
1532 r#type: None,
1533 url: None,
1534 headers: None,
1535 agpm_metadata: Some(AgpmMetadata {
1536 managed: true,
1537 source: Some("test".to_string()),
1538 version: Some("v1.0.0".to_string()),
1539 installed_at: "2024-01-01T00:00:00Z".to_string(),
1540 dependency_name: None,
1541 }),
1542 },
1543 );
1544 mcp_config.mcp_servers.insert(
1545 "user-server".to_string(),
1546 McpServerConfig {
1547 command: Some("user-cmd".to_string()),
1548 args: vec![],
1549 env: None,
1550 r#type: None,
1551 url: None,
1552 headers: None,
1553 agpm_metadata: None,
1554 },
1555 );
1556 mcp_config.save(&mcp_config_path).unwrap();
1557
1558 clean_mcp_servers(project_root).unwrap();
1560
1561 assert!(!server1_path.exists());
1563 assert!(!server2_path.exists());
1564
1565 let updated_mcp_config = McpConfig::load_or_default(&mcp_config_path).unwrap();
1567 assert_eq!(updated_mcp_config.mcp_servers.len(), 1);
1568 assert!(updated_mcp_config.mcp_servers.contains_key("user-server"));
1569 assert!(!updated_mcp_config.mcp_servers.contains_key("agpm-server"));
1570 }
1571
1572 #[test]
1573 fn test_clean_mcp_servers_no_servers() {
1574 let temp = tempfile::TempDir::new().unwrap();
1575 let project_root = temp.path();
1576
1577 let result = clean_mcp_servers(project_root);
1579 assert!(result.is_ok());
1580 }
1581
1582 #[test]
1583 fn test_list_mcp_servers() {
1584 let temp = tempfile::TempDir::new().unwrap();
1585 let project_root = temp.path();
1586 let claude_dir = project_root.join(".claude");
1587 let settings_path = claude_dir.join("settings.local.json");
1588
1589 std::fs::create_dir_all(&claude_dir).unwrap();
1590
1591 let mut settings = ClaudeSettings::default();
1593 let mut servers = HashMap::new();
1594
1595 servers.insert(
1596 "managed-server".to_string(),
1597 McpServerConfig {
1598 command: Some("managed".to_string()),
1599 args: vec![],
1600 env: None,
1601 r#type: None,
1602 url: None,
1603 headers: None,
1604 agpm_metadata: Some(AgpmMetadata {
1605 managed: true,
1606 source: Some("test".to_string()),
1607 version: Some("v2.0.0".to_string()),
1608 installed_at: "2024-01-01T00:00:00Z".to_string(),
1609 dependency_name: None,
1610 }),
1611 },
1612 );
1613
1614 servers.insert(
1615 "user-server".to_string(),
1616 McpServerConfig {
1617 command: Some("user".to_string()),
1618 args: vec![],
1619 env: None,
1620 r#type: None,
1621 url: None,
1622 headers: None,
1623 agpm_metadata: None,
1624 },
1625 );
1626
1627 settings.mcp_servers = Some(servers);
1628 settings.save(&settings_path).unwrap();
1629
1630 let result = list_mcp_servers(project_root);
1632 assert!(result.is_ok());
1633 }
1634
1635 #[test]
1636 fn test_list_mcp_servers_no_file() {
1637 let temp = tempfile::TempDir::new().unwrap();
1638 let project_root = temp.path();
1639
1640 let result = list_mcp_servers(project_root);
1642 assert!(result.is_ok());
1643 }
1644
1645 #[test]
1646 fn test_list_mcp_servers_empty() {
1647 let temp = tempfile::TempDir::new().unwrap();
1648 let project_root = temp.path();
1649 let claude_dir = project_root.join(".claude");
1650 let settings_path = claude_dir.join("settings.local.json");
1651
1652 std::fs::create_dir_all(&claude_dir).unwrap();
1653
1654 let settings = ClaudeSettings::default();
1656 settings.save(&settings_path).unwrap();
1657
1658 let result = list_mcp_servers(project_root);
1660 assert!(result.is_ok());
1661 }
1662
1663 #[test]
1664 fn test_claude_settings_save_backup() {
1665 let temp = tempfile::TempDir::new().unwrap();
1666 setup_project_root(temp.path());
1667
1668 let settings_path = temp.path().join("settings.local.json");
1669 let backup_path = temp
1670 .path()
1671 .join(".agpm")
1672 .join("backups")
1673 .join("claude-code")
1674 .join("settings.local.json");
1675
1676 let settings1 = ClaudeSettings::default();
1678 settings1.save(&settings_path).unwrap();
1679 assert!(settings_path.exists());
1680 assert!(!backup_path.exists());
1681
1682 let settings2 = ClaudeSettings {
1684 hooks: Some(serde_json::json!({"test": "hook"})),
1685 ..Default::default()
1686 };
1687 settings2.save(&settings_path).unwrap();
1688
1689 assert!(backup_path.exists());
1691
1692 let backup_content: ClaudeSettings = crate::utils::read_json_file(&backup_path).unwrap();
1694 assert!(backup_content.hooks.is_none());
1695
1696 let main_content: ClaudeSettings = crate::utils::read_json_file(&settings_path).unwrap();
1698 assert!(main_content.hooks.is_some());
1699 }
1700
1701 #[test]
1702 fn test_mcp_config_save_backup() {
1703 let temp = tempfile::TempDir::new().unwrap();
1704 setup_project_root(temp.path());
1705
1706 let config_path = temp.path().join(".mcp.json");
1707 let backup_path =
1708 temp.path().join(".agpm").join("backups").join("claude-code").join(".mcp.json");
1709
1710 let config1 = McpConfig::default();
1712 config1.save(&config_path).unwrap();
1713 assert!(config_path.exists());
1714 assert!(!backup_path.exists());
1715
1716 let mut config2 = McpConfig::default();
1718 config2.mcp_servers.insert(
1719 "test".to_string(),
1720 McpServerConfig {
1721 command: Some("test-cmd".to_string()),
1722 args: vec![],
1723 env: None,
1724 r#type: None,
1725 url: None,
1726 headers: None,
1727 agpm_metadata: None,
1728 },
1729 );
1730 config2.save(&config_path).unwrap();
1731
1732 assert!(backup_path.exists());
1734
1735 let backup_content: McpConfig = crate::utils::read_json_file(&backup_path).unwrap();
1737 assert!(backup_content.mcp_servers.is_empty());
1738
1739 let main_content: McpConfig = crate::utils::read_json_file(&config_path).unwrap();
1741 assert_eq!(main_content.mcp_servers.len(), 1);
1742 }
1743
1744 #[test]
1745 fn test_backup_fails_without_project_root() {
1746 let temp = tempfile::TempDir::new().unwrap();
1748 let settings_path = temp.path().join("settings.local.json");
1751
1752 fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
1754
1755 let settings = ClaudeSettings::default();
1756 let result = settings.save(&settings_path);
1757
1758 assert!(result.is_err());
1760 let error_msg = result.unwrap_err().to_string();
1761 assert!(
1762 error_msg.contains("Failed to find project root") || error_msg.contains("agpm.toml")
1763 );
1764 }
1765
1766 #[test]
1767 fn test_update_mcp_servers_preserves_user_servers() {
1768 let temp = tempfile::TempDir::new().unwrap();
1769 let agpm_dir = temp.path().join(".claude").join("agpm");
1770 let mcp_servers_dir = agpm_dir.join("mcp-servers");
1771 std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1772
1773 let server1 = McpServerConfig {
1775 command: Some("server1".to_string()),
1776 args: vec!["arg1".to_string()],
1777 env: None,
1778 r#type: None,
1779 url: None,
1780 headers: None,
1781 agpm_metadata: Some(AgpmMetadata {
1782 managed: true,
1783 source: Some("source1".to_string()),
1784 version: Some("v1.0.0".to_string()),
1785 installed_at: "2024-01-01T00:00:00Z".to_string(),
1786 dependency_name: None,
1787 }),
1788 };
1789 crate::utils::write_json_file(&mcp_servers_dir.join("server1.json"), &server1, true)
1790 .unwrap();
1791
1792 let mut settings = ClaudeSettings::default();
1794 let mut servers = HashMap::new();
1795 servers.insert(
1796 "user-server".to_string(),
1797 McpServerConfig {
1798 command: Some("user".to_string()),
1799 args: vec![],
1800 env: None,
1801 r#type: None,
1802 url: None,
1803 headers: None,
1804 agpm_metadata: None,
1805 },
1806 );
1807 settings.mcp_servers = Some(servers);
1808
1809 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
1811
1812 let servers = settings.mcp_servers.as_ref().unwrap();
1814 assert_eq!(servers.len(), 2);
1815 assert!(servers.contains_key("user-server"));
1816 assert!(servers.contains_key("server1"));
1817
1818 let server1_config = servers.get("server1").unwrap();
1820 assert_eq!(server1_config.command, Some("server1".to_string()));
1821 assert_eq!(server1_config.args, vec!["arg1"]);
1822 }
1823
1824 #[test]
1825 fn test_update_mcp_servers_nonexistent_dir() {
1826 let temp = tempfile::TempDir::new().unwrap();
1827 let nonexistent_dir = temp.path().join("nonexistent");
1828
1829 let mut settings = ClaudeSettings::default();
1830 let result = settings.update_mcp_servers(&nonexistent_dir);
1831 assert!(result.is_ok());
1832 }
1833
1834 #[test]
1835 fn test_mcp_config_handles_extra_fields() {
1836 let json_str = r#"{
1838 "mcpServers": {
1839 "test": {
1840 "command": "test",
1841 "args": []
1842 }
1843 },
1844 "customField": "value",
1845 "anotherField": {
1846 "nested": true
1847 }
1848 }"#;
1849
1850 let temp = tempdir().unwrap();
1851 let config_path = temp.path().join(".mcp.json");
1852 std::fs::write(&config_path, json_str).unwrap();
1853
1854 let config = McpConfig::load_or_default(&config_path).unwrap();
1856 assert!(config.mcp_servers.contains_key("test"));
1857 assert_eq!(config.mcp_servers.len(), 1);
1858 }
1859
1860 #[tokio::test]
1863 async fn test_merge_mcp_servers_unchanged_detection() {
1864 let temp = tempdir().unwrap();
1865 setup_project_root(temp.path());
1866 let config_path = temp.path().join(".mcp.json");
1867
1868 let initial_config = json!({
1870 "mcpServers": {
1871 "test-server": {
1872 "command": "node",
1873 "args": ["server.js"],
1874 "_agpm": {
1875 "managed": true,
1876 "source": "test-source",
1877 "version": "v1.0.0",
1878 "installed_at": "2024-01-01T00:00:00Z"
1879 }
1880 }
1881 }
1882 });
1883
1884 tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
1885 .await
1886 .unwrap();
1887
1888 let mut agpm_servers = HashMap::new();
1890 agpm_servers.insert(
1891 "test-server".to_string(),
1892 McpServerConfig {
1893 command: Some("node".to_string()),
1894 args: vec!["server.js".to_string()],
1895 env: None,
1896 r#type: None,
1897 url: None,
1898 headers: None,
1899 agpm_metadata: Some(AgpmMetadata {
1900 managed: true,
1901 source: Some("test-source".to_string()),
1902 version: Some("v1.0.0".to_string()),
1903 installed_at: "2024-01-02T00:00:00Z".to_string(), dependency_name: None,
1905 }),
1906 },
1907 );
1908
1909 let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
1911 assert_eq!(changed_count, 0, "Should detect no changes when only timestamp differs");
1912 }
1913
1914 #[tokio::test]
1915 async fn test_merge_mcp_servers_actual_change() {
1916 let temp = tempdir().unwrap();
1917 setup_project_root(temp.path());
1918 let config_path = temp.path().join(".mcp.json");
1919
1920 let initial_config = json!({
1922 "mcpServers": {
1923 "test-server": {
1924 "command": "node",
1925 "args": ["server.js"],
1926 "_agpm": {
1927 "managed": true,
1928 "source": "test-source",
1929 "version": "v1.0.0",
1930 "installed_at": "2024-01-01T00:00:00Z"
1931 }
1932 }
1933 }
1934 });
1935
1936 tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
1937 .await
1938 .unwrap();
1939
1940 let mut agpm_servers = HashMap::new();
1942 agpm_servers.insert(
1943 "test-server".to_string(),
1944 McpServerConfig {
1945 command: Some("python".to_string()), args: vec!["server.py".to_string()],
1947 env: None,
1948 r#type: None,
1949 url: None,
1950 headers: None,
1951 agpm_metadata: Some(AgpmMetadata {
1952 managed: true,
1953 source: Some("test-source".to_string()),
1954 version: Some("v1.0.0".to_string()),
1955 installed_at: "2024-01-01T00:00:00Z".to_string(),
1956 dependency_name: None,
1957 }),
1958 },
1959 );
1960
1961 let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
1963 assert_eq!(changed_count, 1, "Should detect changes when server configuration differs");
1964 }
1965
1966 #[tokio::test]
1967 async fn test_merge_mcp_servers_new_server() {
1968 let temp = tempdir().unwrap();
1969 setup_project_root(temp.path());
1970 let config_path = temp.path().join(".mcp.json");
1971
1972 let initial_config = json!({
1974 "mcpServers": {}
1975 });
1976
1977 tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
1978 .await
1979 .unwrap();
1980
1981 let mut agpm_servers = HashMap::new();
1983 agpm_servers.insert(
1984 "new-server".to_string(),
1985 McpServerConfig {
1986 command: Some("node".to_string()),
1987 args: vec!["server.js".to_string()],
1988 env: None,
1989 r#type: None,
1990 url: None,
1991 headers: None,
1992 agpm_metadata: Some(AgpmMetadata {
1993 managed: true,
1994 source: Some("test-source".to_string()),
1995 version: Some("v1.0.0".to_string()),
1996 installed_at: "2024-01-01T00:00:00Z".to_string(),
1997 dependency_name: None,
1998 }),
1999 },
2000 );
2001
2002 let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
2004 assert_eq!(changed_count, 1, "Should detect new server as changed");
2005 }
2006
2007 #[tokio::test]
2008 async fn test_merge_mcp_servers_mixed_changes() {
2009 let temp = tempdir().unwrap();
2010 setup_project_root(temp.path());
2011 let config_path = temp.path().join(".mcp.json");
2012
2013 let initial_config = json!({
2015 "mcpServers": {
2016 "unchanged-server": {
2017 "command": "node",
2018 "args": ["server.js"],
2019 "_agpm": {
2020 "managed": true,
2021 "source": "test-source",
2022 "version": "v1.0.0",
2023 "installed_at": "2024-01-01T00:00:00Z"
2024 }
2025 },
2026 "changed-server": {
2027 "command": "python",
2028 "args": ["server.py"],
2029 "_agpm": {
2030 "managed": true,
2031 "source": "test-source",
2032 "version": "v1.0.0",
2033 "installed_at": "2024-01-01T00:00:00Z"
2034 }
2035 }
2036 }
2037 });
2038
2039 tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
2040 .await
2041 .unwrap();
2042
2043 let mut agpm_servers = HashMap::new();
2045
2046 agpm_servers.insert(
2048 "unchanged-server".to_string(),
2049 McpServerConfig {
2050 command: Some("node".to_string()),
2051 args: vec!["server.js".to_string()],
2052 env: None,
2053 r#type: None,
2054 url: None,
2055 headers: None,
2056 agpm_metadata: Some(AgpmMetadata {
2057 managed: true,
2058 source: Some("test-source".to_string()),
2059 version: Some("v1.0.0".to_string()),
2060 installed_at: "2024-01-02T00:00:00Z".to_string(), dependency_name: None,
2062 }),
2063 },
2064 );
2065
2066 agpm_servers.insert(
2068 "changed-server".to_string(),
2069 McpServerConfig {
2070 command: Some("ruby".to_string()), args: vec!["server.rb".to_string()],
2072 env: None,
2073 r#type: None,
2074 url: None,
2075 headers: None,
2076 agpm_metadata: Some(AgpmMetadata {
2077 managed: true,
2078 source: Some("test-source".to_string()),
2079 version: Some("v1.0.0".to_string()),
2080 installed_at: "2024-01-01T00:00:00Z".to_string(),
2081 dependency_name: None,
2082 }),
2083 },
2084 );
2085
2086 agpm_servers.insert(
2088 "new-server".to_string(),
2089 McpServerConfig {
2090 command: Some("go".to_string()),
2091 args: vec!["server".to_string()],
2092 env: None,
2093 r#type: None,
2094 url: None,
2095 headers: None,
2096 agpm_metadata: Some(AgpmMetadata {
2097 managed: true,
2098 source: Some("test-source".to_string()),
2099 version: Some("v1.0.0".to_string()),
2100 installed_at: "2024-01-01T00:00:00Z".to_string(),
2101 dependency_name: None,
2102 }),
2103 },
2104 );
2105
2106 let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
2108 assert_eq!(changed_count, 2, "Should detect 2 changes: 1 modified server + 1 new server");
2109 }
2110
2111 #[tokio::test]
2112 async fn test_merge_mcp_servers_empty_updates() {
2113 let temp = tempdir().unwrap();
2114 setup_project_root(temp.path());
2115 let config_path = temp.path().join(".mcp.json");
2116
2117 let initial_config = json!({
2119 "mcpServers": {
2120 "existing-server": {
2121 "command": "node",
2122 "args": ["server.js"],
2123 "_agpm": {
2124 "managed": true,
2125 "source": "test-source",
2126 "version": "v1.0.0",
2127 "installed_at": "2024-01-01T00:00:00Z"
2128 }
2129 }
2130 }
2131 });
2132
2133 tokio::fs::write(&config_path, serde_json::to_string_pretty(&initial_config).unwrap())
2134 .await
2135 .unwrap();
2136
2137 let agpm_servers = HashMap::new();
2139
2140 let changed_count = merge_mcp_servers(&config_path, agpm_servers).await.unwrap();
2142 assert_eq!(changed_count, 0, "Should detect 0 changes when only removing servers");
2143 }
2144}