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)]
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)]
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(
344 mcp_config_path: &Path,
345 agpm_servers: HashMap<String, McpServerConfig>,
346) -> Result<()> {
347 if agpm_servers.is_empty() {
348 return Ok(());
349 }
350
351 let mut mcp_config = McpConfig::load_or_default(mcp_config_path)?;
353
354 let conflicts = mcp_config.check_conflicts(&agpm_servers);
356 if !conflicts.is_empty() {
357 return Err(anyhow::anyhow!(
358 "The following MCP servers already exist and are not managed by AGPM: {}\n\
359 Please rename your servers or remove the existing ones from .mcp.json",
360 conflicts.join(", ")
361 ));
362 }
363
364 mcp_config.update_managed_servers(agpm_servers)?;
366
367 mcp_config.save(mcp_config_path)?;
369
370 Ok(())
371}
372
373pub async fn configure_mcp_servers(project_root: &Path, mcp_servers_dir: &Path) -> Result<()> {
374 if !mcp_servers_dir.exists() {
375 return Ok(());
376 }
377
378 let mcp_config_path = project_root.join(".mcp.json");
379
380 let mut agpm_servers = HashMap::new();
382 let mut entries = tokio::fs::read_dir(mcp_servers_dir).await?;
383 while let Some(entry) = entries.next_entry().await? {
384 let path = entry.path();
385
386 if path.extension().is_some_and(|ext| ext == "json")
387 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
388 {
389 let config: McpServerConfig = crate::utils::read_json_file(&path)
391 .with_context(|| format!("Failed to parse MCP server file: {}", path.display()))?;
392
393 let mut config_with_metadata = config;
395 if config_with_metadata.agpm_metadata.is_none() {
396 config_with_metadata.agpm_metadata = Some(AgpmMetadata {
397 managed: true,
398 source: Some("agpm".to_string()),
399 version: None,
400 installed_at: Utc::now().to_rfc3339(),
401 dependency_name: Some(name.to_string()),
402 });
403 }
404
405 agpm_servers.insert(name.to_string(), config_with_metadata);
406 }
407 }
408
409 merge_mcp_servers(&mcp_config_path, agpm_servers).await
411}
412
413pub fn clean_mcp_servers(project_root: &Path) -> Result<()> {
415 let claude_dir = project_root.join(".claude");
416 let agpm_dir = claude_dir.join("agpm");
417 let mcp_servers_dir = agpm_dir.join("mcp-servers");
418 let mcp_config_path = project_root.join(".mcp.json");
419
420 let mut removed_count = 0;
422 if mcp_servers_dir.exists() {
423 for entry in std::fs::read_dir(&mcp_servers_dir)? {
424 let entry = entry?;
425 let path = entry.path();
426 if path.extension().is_some_and(|ext| ext == "json") {
427 std::fs::remove_file(&path).with_context(|| {
428 format!("Failed to remove MCP server file: {}", path.display())
429 })?;
430 removed_count += 1;
431 }
432 }
433 }
434
435 if mcp_config_path.exists() {
437 let mut mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
438 mcp_config.remove_all_managed();
439 mcp_config.save(&mcp_config_path)?;
440 }
441
442 if removed_count == 0 {
443 println!("No AGPM-managed MCP servers found");
444 } else {
445 println!("✓ Removed {removed_count} AGPM-managed MCP server(s)");
446 }
447
448 Ok(())
449}
450
451pub fn list_mcp_servers(project_root: &Path) -> Result<()> {
453 let mcp_config_path = project_root.join(".mcp.json");
454
455 if !mcp_config_path.exists() {
456 println!("No .mcp.json file found");
457 return Ok(());
458 }
459
460 let mcp_config = McpConfig::load_or_default(&mcp_config_path)?;
461
462 if mcp_config.mcp_servers.is_empty() {
463 println!("No MCP servers configured");
464 return Ok(());
465 }
466
467 let servers = &mcp_config.mcp_servers;
468 println!("MCP Servers:");
469 println!("â•─────────────────────┬──────────┬───────────╮");
470 println!("│ Name │ Managed │ Version │");
471 println!("├─────────────────────┼──────────┼───────────┤");
472
473 for (name, server) in servers {
474 let (managed, version) = if let Some(meta) = &server.agpm_metadata {
475 if meta.managed {
476 ("✓ (agpm)", meta.version.as_deref().unwrap_or("-"))
477 } else {
478 ("✗", "-")
479 }
480 } else {
481 ("✗", "-")
482 };
483
484 println!("│ {name:<19} │ {managed:<8} │ {version:<9} │");
485 }
486
487 println!("╰─────────────────────┴──────────┴───────────╯");
488
489 Ok(())
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use serde_json::json;
496 use std::fs;
497 use tempfile::tempdir;
498
499 fn setup_project_root(temp_path: &std::path::Path) {
501 fs::write(temp_path.join("agpm.toml"), "[dependencies]\n").unwrap();
502 }
503
504 #[test]
505 fn test_claude_settings_load_save() {
506 let temp = tempdir().unwrap();
507 let settings_path = temp.path().join("settings.local.json");
508
509 let mut settings = ClaudeSettings::default();
510 let mut servers = HashMap::new();
511 servers.insert(
512 "test-server".to_string(),
513 McpServerConfig {
514 command: Some("node".to_string()),
515 args: vec!["server.js".to_string()],
516 env: None,
517 r#type: None,
518 url: None,
519 headers: None,
520 agpm_metadata: None,
521 },
522 );
523 settings.mcp_servers = Some(servers);
524
525 settings.save(&settings_path).unwrap();
526
527 let loaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
528 assert!(loaded.mcp_servers.is_some());
529 let servers = loaded.mcp_servers.unwrap();
530 assert_eq!(servers.len(), 1);
531 assert!(servers.contains_key("test-server"));
532 }
533
534 #[test]
535 fn test_claude_settings_load_nonexistent_file() {
536 let temp = tempdir().unwrap();
537 let settings_path = temp.path().join("nonexistent.json");
538
539 let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
540 assert!(settings.mcp_servers.is_none());
541 assert!(settings.permissions.is_none());
542 assert!(settings.other.is_empty());
543 }
544
545 #[test]
546 fn test_claude_settings_load_invalid_json() {
547 let temp = tempdir().unwrap();
548 let settings_path = temp.path().join("invalid.json");
549 fs::write(&settings_path, "invalid json {").unwrap();
550
551 let result = ClaudeSettings::load_or_default(&settings_path);
552 assert!(result.is_err());
553 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
554 }
555
556 #[test]
557 fn test_claude_settings_save_creates_backup() {
558 let temp = tempdir().unwrap();
559 setup_project_root(temp.path());
560
561 let settings_path = temp.path().join("settings.local.json");
562 let backup_path = temp
563 .path()
564 .join(".agpm")
565 .join("backups")
566 .join("claude-code")
567 .join("settings.local.json");
568
569 fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
571
572 let settings = ClaudeSettings::default();
573 settings.save(&settings_path).unwrap();
574
575 assert!(backup_path.exists());
577 let backup_content = fs::read_to_string(backup_path).unwrap();
578 assert_eq!(backup_content, r#"{"test": "value"}"#);
579 }
580
581 #[test]
582 fn test_claude_settings_update_mcp_servers_empty_dir() {
583 let temp = tempdir().unwrap();
584 let nonexistent_dir = temp.path().join("nonexistent");
585
586 let mut settings = ClaudeSettings::default();
587 settings.update_mcp_servers(&nonexistent_dir).unwrap();
589 }
590
591 #[test]
592 fn test_update_mcp_servers_from_directory() {
593 let temp = tempdir().unwrap();
594 let mcp_servers_dir = temp.path().join("mcp-servers");
595 std::fs::create_dir(&mcp_servers_dir).unwrap();
596
597 let server_config = McpServerConfig {
599 command: Some("managed".to_string()),
600 args: vec![],
601 env: None,
602 r#type: None,
603 url: None,
604 headers: None,
605 agpm_metadata: Some(AgpmMetadata {
606 managed: true,
607 source: Some("test".to_string()),
608 version: Some("v1.0.0".to_string()),
609 installed_at: "2024-01-01T00:00:00Z".to_string(),
610 dependency_name: Some("agpm-server".to_string()),
611 }),
612 };
613 let config_path = mcp_servers_dir.join("agpm-server.json");
614 let json = serde_json::to_string_pretty(&server_config).unwrap();
615 std::fs::write(&config_path, json).unwrap();
616
617 let mut settings = ClaudeSettings::default();
619 let mut servers = HashMap::new();
620 servers.insert(
621 "user-server".to_string(),
622 McpServerConfig {
623 command: Some("custom".to_string()),
624 args: vec![],
625 env: None,
626 r#type: None,
627 url: None,
628 headers: None,
629 agpm_metadata: None,
630 },
631 );
632 settings.mcp_servers = Some(servers);
633
634 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
636
637 assert!(settings.mcp_servers.is_some());
639 let servers = settings.mcp_servers.as_ref().unwrap();
640 assert!(servers.contains_key("user-server"));
641 assert!(servers.contains_key("agpm-server"));
642 assert_eq!(servers.len(), 2);
643 }
644
645 #[test]
646 fn test_update_mcp_servers_replaces_old_managed() {
647 let temp = tempdir().unwrap();
648 let mcp_servers_dir = temp.path().join("mcp-servers");
649 std::fs::create_dir(&mcp_servers_dir).unwrap();
650
651 let mut settings = ClaudeSettings::default();
653 let mut servers = HashMap::new();
654
655 servers.insert(
657 "user-server".to_string(),
658 McpServerConfig {
659 command: Some("user-command".to_string()),
660 args: vec![],
661 env: None,
662 r#type: None,
663 url: None,
664 headers: None,
665 agpm_metadata: None,
666 },
667 );
668
669 servers.insert(
671 "old-managed".to_string(),
672 McpServerConfig {
673 command: Some("old-command".to_string()),
674 args: vec![],
675 env: None,
676 r#type: None,
677 url: None,
678 headers: None,
679 agpm_metadata: Some(AgpmMetadata {
680 managed: true,
681 source: Some("old-source".to_string()),
682 version: Some("v0.1.0".to_string()),
683 installed_at: "2023-01-01T00:00:00Z".to_string(),
684 dependency_name: Some("old-managed".to_string()),
685 }),
686 },
687 );
688
689 settings.mcp_servers = Some(servers);
690
691 let server_config = McpServerConfig {
693 command: Some("new-managed".to_string()),
694 args: vec![],
695 env: None,
696 r#type: None,
697 url: None,
698 headers: None,
699 agpm_metadata: Some(AgpmMetadata {
700 managed: true,
701 source: Some("new-source".to_string()),
702 version: Some("v1.0.0".to_string()),
703 installed_at: "2024-01-01T00:00:00Z".to_string(),
704 dependency_name: Some("new-managed".to_string()),
705 }),
706 };
707 let config_path = mcp_servers_dir.join("new-managed.json");
708 let json = serde_json::to_string_pretty(&server_config).unwrap();
709 std::fs::write(&config_path, json).unwrap();
710
711 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
713
714 let servers = settings.mcp_servers.as_ref().unwrap();
715 assert!(servers.contains_key("user-server")); assert!(servers.contains_key("new-managed")); assert!(!servers.contains_key("old-managed")); assert_eq!(servers.len(), 2);
719 }
720
721 #[test]
722 fn test_update_mcp_servers_invalid_json_file() {
723 let temp = tempdir().unwrap();
724 let mcp_servers_dir = temp.path().join("mcp-servers");
725 std::fs::create_dir(&mcp_servers_dir).unwrap();
726
727 let invalid_path = mcp_servers_dir.join("invalid.json");
729 std::fs::write(&invalid_path, "invalid json {").unwrap();
730
731 let mut settings = ClaudeSettings::default();
732 let result = settings.update_mcp_servers(&mcp_servers_dir);
733 assert!(result.is_err());
734 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
735 }
736
737 #[test]
738 fn test_update_mcp_servers_ignores_non_json_files() {
739 let temp = tempdir().unwrap();
740 let mcp_servers_dir = temp.path().join("mcp-servers");
741 std::fs::create_dir(&mcp_servers_dir).unwrap();
742
743 let txt_path = mcp_servers_dir.join("readme.txt");
745 std::fs::write(&txt_path, "This is not a JSON file").unwrap();
746
747 let server_config = McpServerConfig {
749 command: Some("test".to_string()),
750 args: vec![],
751 env: None,
752 r#type: None,
753 url: None,
754 headers: None,
755 agpm_metadata: None,
756 };
757 let json_path = mcp_servers_dir.join("valid.json");
758 let json = serde_json::to_string_pretty(&server_config).unwrap();
759 std::fs::write(&json_path, json).unwrap();
760
761 let mut settings = ClaudeSettings::default();
762 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
763
764 let servers = settings.mcp_servers.as_ref().unwrap();
765 assert!(servers.contains_key("valid"));
766 assert_eq!(servers.len(), 1);
767 }
768
769 #[test]
770 fn test_settings_preserves_other_fields() {
771 let temp = tempdir().unwrap();
772 setup_project_root(temp.path());
773
774 let settings_path = temp.path().join("settings.local.json");
775
776 let json = r#"{
778 "permissions": {
779 "allow": ["Bash(ls)"],
780 "deny": []
781 },
782 "customField": "value",
783 "mcpServers": {
784 "test": {
785 "command": "node",
786 "args": []
787 }
788 }
789 }"#;
790 std::fs::write(&settings_path, json).unwrap();
791
792 let settings = ClaudeSettings::load_or_default(&settings_path).unwrap();
794 assert!(settings.permissions.is_some());
795 assert!(settings.mcp_servers.is_some());
796 assert!(settings.other.contains_key("customField"));
797
798 settings.save(&settings_path).unwrap();
799
800 let reloaded = ClaudeSettings::load_or_default(&settings_path).unwrap();
802 assert!(reloaded.permissions.is_some());
803 assert!(reloaded.mcp_servers.is_some());
804 assert!(reloaded.other.contains_key("customField"));
805 }
806
807 #[test]
809 fn test_mcp_config_load_save() {
810 let temp = tempdir().unwrap();
811 let config_path = temp.path().join("mcp.json");
812
813 let mut config = McpConfig::default();
814 config.mcp_servers.insert(
815 "test-server".to_string(),
816 McpServerConfig {
817 command: Some("node".to_string()),
818 args: vec!["server.js".to_string()],
819 env: Some({
820 let mut env = HashMap::new();
821 env.insert("NODE_ENV".to_string(), json!("production"));
822 env
823 }),
824 r#type: None,
825 url: None,
826 headers: None,
827 agpm_metadata: None,
828 },
829 );
830
831 config.save(&config_path).unwrap();
832
833 let loaded = McpConfig::load_or_default(&config_path).unwrap();
834 assert!(loaded.mcp_servers.contains_key("test-server"));
835 let server = &loaded.mcp_servers["test-server"];
836 assert_eq!(server.command, Some("node".to_string()));
837 assert_eq!(server.args, vec!["server.js"]);
838 assert!(server.env.is_some());
839 }
840
841 #[test]
842 fn test_mcp_config_load_nonexistent() {
843 let temp = tempdir().unwrap();
844 let config_path = temp.path().join("nonexistent.json");
845
846 let config = McpConfig::load_or_default(&config_path).unwrap();
847 assert!(config.mcp_servers.is_empty());
848 }
849
850 #[test]
851 fn test_mcp_config_load_invalid_json() {
852 let temp = tempdir().unwrap();
853 let config_path = temp.path().join("invalid.json");
854 fs::write(&config_path, "invalid json {").unwrap();
855
856 let result = McpConfig::load_or_default(&config_path);
857 assert!(result.is_err());
858 }
859
860 #[test]
861 fn test_mcp_config_save_creates_backup() {
862 let temp = tempdir().unwrap();
863 setup_project_root(temp.path());
864
865 let config_path = temp.path().join("mcp.json");
866 let backup_path =
867 temp.path().join(".agpm").join("backups").join("claude-code").join("mcp.json");
868
869 fs::write(&config_path, r#"{"mcpServers": {"old": {"command": "old"}}}"#).unwrap();
871
872 let config = McpConfig::default();
873 config.save(&config_path).unwrap();
874
875 assert!(backup_path.exists());
877 let backup_content = fs::read_to_string(backup_path).unwrap();
878 assert!(backup_content.contains("old"));
879 }
880
881 #[test]
882 fn test_mcp_config_update_managed_servers() {
883 let mut config = McpConfig::default();
884
885 config.mcp_servers.insert(
887 "user-server".to_string(),
888 McpServerConfig {
889 command: Some("user-command".to_string()),
890 args: vec![],
891 env: None,
892 r#type: None,
893 url: None,
894 headers: None,
895 agpm_metadata: None,
896 },
897 );
898
899 config.mcp_servers.insert(
901 "old-managed".to_string(),
902 McpServerConfig {
903 command: Some("old-command".to_string()),
904 args: vec![],
905 env: None,
906 r#type: None,
907 url: None,
908 headers: None,
909 agpm_metadata: Some(AgpmMetadata {
910 managed: true,
911 source: None,
912 version: None,
913 installed_at: "old-time".to_string(),
914 dependency_name: None,
915 }),
916 },
917 );
918
919 let mut updates = HashMap::new();
921 updates.insert(
922 "new-managed".to_string(),
923 McpServerConfig {
924 command: Some("new-command".to_string()),
925 args: vec![],
926 env: None,
927 r#type: None,
928 url: None,
929 headers: None,
930 agpm_metadata: Some(AgpmMetadata {
931 managed: true,
932 source: None,
933 version: None,
934 installed_at: "new-time".to_string(),
935 dependency_name: None,
936 }),
937 },
938 );
939
940 config.update_managed_servers(updates).unwrap();
941
942 assert!(config.mcp_servers.contains_key("user-server"));
944 assert!(config.mcp_servers.contains_key("new-managed"));
945 assert!(!config.mcp_servers.contains_key("old-managed"));
946 assert_eq!(config.mcp_servers.len(), 2);
947 }
948
949 #[test]
950 fn test_mcp_config_update_managed_servers_preserves_updating_servers() {
951 let mut config = McpConfig::default();
952
953 config.mcp_servers.insert(
955 "updating-server".to_string(),
956 McpServerConfig {
957 command: Some("old-command".to_string()),
958 args: vec![],
959 env: None,
960 r#type: None,
961 url: None,
962 headers: None,
963 agpm_metadata: Some(AgpmMetadata {
964 managed: true,
965 source: None,
966 version: Some("v1.0.0".to_string()),
967 installed_at: "old-time".to_string(),
968 dependency_name: None,
969 }),
970 },
971 );
972
973 let mut updates = HashMap::new();
975 updates.insert(
976 "updating-server".to_string(),
977 McpServerConfig {
978 command: Some("new-command".to_string()),
979 args: vec![],
980 env: None,
981 r#type: None,
982 url: None,
983 headers: None,
984 agpm_metadata: Some(AgpmMetadata {
985 managed: true,
986 source: None,
987 version: Some("v2.0.0".to_string()),
988 installed_at: "new-time".to_string(),
989 dependency_name: None,
990 }),
991 },
992 );
993
994 config.update_managed_servers(updates).unwrap();
995
996 assert!(config.mcp_servers.contains_key("updating-server"));
997 let server = &config.mcp_servers["updating-server"];
998 assert_eq!(server.command, Some("new-command".to_string()));
999 assert_eq!(server.agpm_metadata.as_ref().unwrap().version, Some("v2.0.0".to_string()));
1000 }
1001
1002 #[test]
1003 fn test_mcp_config_check_conflicts() {
1004 let mut config = McpConfig::default();
1005
1006 config.mcp_servers.insert(
1008 "user-server".to_string(),
1009 McpServerConfig {
1010 command: Some("user-command".to_string()),
1011 args: vec![],
1012 env: None,
1013 r#type: None,
1014 url: None,
1015 headers: None,
1016 agpm_metadata: None,
1017 },
1018 );
1019
1020 config.mcp_servers.insert(
1022 "managed-server".to_string(),
1023 McpServerConfig {
1024 command: Some("managed-command".to_string()),
1025 args: vec![],
1026 env: None,
1027 r#type: None,
1028 url: None,
1029 headers: None,
1030 agpm_metadata: Some(AgpmMetadata {
1031 managed: true,
1032 source: None,
1033 version: None,
1034 installed_at: "time".to_string(),
1035 dependency_name: None,
1036 }),
1037 },
1038 );
1039
1040 let mut new_servers = HashMap::new();
1041 new_servers.insert(
1042 "user-server".to_string(), McpServerConfig {
1044 command: Some("new-command".to_string()),
1045 args: vec![],
1046 env: None,
1047 r#type: None,
1048 url: None,
1049 headers: None,
1050 agpm_metadata: Some(AgpmMetadata {
1051 managed: true,
1052 source: None,
1053 version: None,
1054 installed_at: "time".to_string(),
1055 dependency_name: None,
1056 }),
1057 },
1058 );
1059 new_servers.insert(
1060 "managed-server".to_string(), McpServerConfig {
1062 command: Some("updated-command".to_string()),
1063 args: vec![],
1064 env: None,
1065 r#type: None,
1066 url: None,
1067 headers: None,
1068 agpm_metadata: Some(AgpmMetadata {
1069 managed: true,
1070 source: None,
1071 version: None,
1072 installed_at: "time".to_string(),
1073 dependency_name: None,
1074 }),
1075 },
1076 );
1077 new_servers.insert(
1078 "new-server".to_string(), McpServerConfig {
1080 command: Some("new-command".to_string()),
1081 args: vec![],
1082 env: None,
1083 r#type: None,
1084 url: None,
1085 headers: None,
1086 agpm_metadata: Some(AgpmMetadata {
1087 managed: true,
1088 source: None,
1089 version: None,
1090 installed_at: "time".to_string(),
1091 dependency_name: None,
1092 }),
1093 },
1094 );
1095
1096 let conflicts = config.check_conflicts(&new_servers);
1097 assert_eq!(conflicts, vec!["user-server"]);
1098 }
1099
1100 #[test]
1101 fn test_mcp_config_check_conflicts_unmanaged_metadata() {
1102 let mut config = McpConfig::default();
1103
1104 config.mcp_servers.insert(
1106 "unmanaged-server".to_string(),
1107 McpServerConfig {
1108 command: Some("user-command".to_string()),
1109 args: vec![],
1110 env: None,
1111 r#type: None,
1112 url: None,
1113 headers: None,
1114 agpm_metadata: Some(AgpmMetadata {
1115 managed: false,
1116 source: None,
1117 version: None,
1118 installed_at: "time".to_string(),
1119 dependency_name: None,
1120 }),
1121 },
1122 );
1123
1124 let mut new_servers = HashMap::new();
1125 new_servers.insert(
1126 "unmanaged-server".to_string(),
1127 McpServerConfig {
1128 command: Some("new-command".to_string()),
1129 args: vec![],
1130 env: None,
1131 r#type: None,
1132 url: None,
1133 headers: None,
1134 agpm_metadata: Some(AgpmMetadata {
1135 managed: true,
1136 source: None,
1137 version: None,
1138 installed_at: "time".to_string(),
1139 dependency_name: None,
1140 }),
1141 },
1142 );
1143
1144 let conflicts = config.check_conflicts(&new_servers);
1145 assert_eq!(conflicts, vec!["unmanaged-server"]);
1146 }
1147
1148 #[test]
1149 fn test_mcp_config_remove_all_managed() {
1150 let mut config = McpConfig::default();
1151
1152 config.mcp_servers.insert(
1154 "user-server".to_string(),
1155 McpServerConfig {
1156 command: Some("user-command".to_string()),
1157 args: vec![],
1158 env: None,
1159 r#type: None,
1160 url: None,
1161 headers: None,
1162 agpm_metadata: None,
1163 },
1164 );
1165
1166 config.mcp_servers.insert(
1167 "managed-server".to_string(),
1168 McpServerConfig {
1169 command: Some("managed-command".to_string()),
1170 args: vec![],
1171 env: None,
1172 r#type: None,
1173 url: None,
1174 headers: None,
1175 agpm_metadata: Some(AgpmMetadata {
1176 managed: true,
1177 source: None,
1178 version: None,
1179 installed_at: "time".to_string(),
1180 dependency_name: None,
1181 }),
1182 },
1183 );
1184
1185 config.mcp_servers.insert(
1186 "unmanaged-with-metadata".to_string(),
1187 McpServerConfig {
1188 command: Some("unmanaged-command".to_string()),
1189 args: vec![],
1190 env: None,
1191 r#type: None,
1192 url: None,
1193 headers: None,
1194 agpm_metadata: Some(AgpmMetadata {
1195 managed: false,
1196 source: None,
1197 version: None,
1198 installed_at: "time".to_string(),
1199 dependency_name: None,
1200 }),
1201 },
1202 );
1203
1204 config.remove_all_managed();
1205
1206 assert!(config.mcp_servers.contains_key("user-server"));
1207 assert!(config.mcp_servers.contains_key("unmanaged-with-metadata"));
1208 assert!(!config.mcp_servers.contains_key("managed-server"));
1209 assert_eq!(config.mcp_servers.len(), 2);
1210 }
1211
1212 #[test]
1213 fn test_mcp_config_get_managed_servers() {
1214 let mut config = McpConfig::default();
1215
1216 config.mcp_servers.insert(
1218 "user-server".to_string(),
1219 McpServerConfig {
1220 command: Some("user-command".to_string()),
1221 args: vec![],
1222 env: None,
1223 r#type: None,
1224 url: None,
1225 headers: None,
1226 agpm_metadata: None,
1227 },
1228 );
1229
1230 config.mcp_servers.insert(
1231 "managed-server1".to_string(),
1232 McpServerConfig {
1233 command: Some("managed-command1".to_string()),
1234 args: vec![],
1235 env: None,
1236 r#type: None,
1237 url: None,
1238 headers: None,
1239 agpm_metadata: Some(AgpmMetadata {
1240 managed: true,
1241 source: None,
1242 version: None,
1243 installed_at: "time".to_string(),
1244 dependency_name: None,
1245 }),
1246 },
1247 );
1248
1249 config.mcp_servers.insert(
1250 "managed-server2".to_string(),
1251 McpServerConfig {
1252 command: Some("managed-command2".to_string()),
1253 args: vec![],
1254 env: None,
1255 r#type: None,
1256 url: None,
1257 headers: None,
1258 agpm_metadata: Some(AgpmMetadata {
1259 managed: true,
1260 source: Some("source".to_string()),
1261 version: Some("v1.0.0".to_string()),
1262 installed_at: "time".to_string(),
1263 dependency_name: Some("dep".to_string()),
1264 }),
1265 },
1266 );
1267
1268 let managed = config.get_managed_servers();
1269 assert_eq!(managed.len(), 2);
1270 assert!(managed.contains_key("managed-server1"));
1271 assert!(managed.contains_key("managed-server2"));
1272 assert!(!managed.contains_key("user-server"));
1273 }
1274
1275 #[test]
1281 fn test_claude_settings_serialization() {
1282 let mut settings = ClaudeSettings {
1284 permissions: Some(json!({"allow": ["test"], "deny": []})),
1285 ..Default::default()
1286 };
1287
1288 let mut servers = HashMap::new();
1289 servers.insert(
1290 "test".to_string(),
1291 McpServerConfig {
1292 command: Some("test-cmd".to_string()),
1293 args: vec!["arg1".to_string()],
1294 env: Some({
1295 let mut env = HashMap::new();
1296 env.insert("VAR".to_string(), json!("value"));
1297 env
1298 }),
1299 r#type: None,
1300 url: None,
1301 headers: None,
1302 agpm_metadata: Some(AgpmMetadata {
1303 managed: true,
1304 source: Some("test-source".to_string()),
1305 version: Some("v1.0.0".to_string()),
1306 installed_at: "2024-01-01T00:00:00Z".to_string(),
1307 dependency_name: Some("test".to_string()),
1308 }),
1309 },
1310 );
1311 settings.mcp_servers = Some(servers);
1312
1313 settings.other.insert("custom".to_string(), json!("value"));
1314
1315 let json = serde_json::to_string(&settings).unwrap();
1317 let deserialized: ClaudeSettings = serde_json::from_str(&json).unwrap();
1318
1319 assert_eq!(deserialized.permissions, settings.permissions);
1320 assert_eq!(deserialized.mcp_servers.as_ref().unwrap().len(), 1);
1321 assert_eq!(deserialized.other.get("custom"), settings.other.get("custom"));
1322 }
1323
1324 #[test]
1325 fn test_mcp_config_serialization() {
1326 let mut config = McpConfig::default();
1327
1328 config.mcp_servers.insert(
1329 "test".to_string(),
1330 McpServerConfig {
1331 command: Some("test-cmd".to_string()),
1332 args: vec!["arg1".to_string(), "arg2".to_string()],
1333 env: Some({
1334 let mut env = HashMap::new();
1335 env.insert("TEST_VAR".to_string(), json!("test_value"));
1336 env
1337 }),
1338 r#type: None,
1339 url: None,
1340 headers: None,
1341 agpm_metadata: Some(AgpmMetadata {
1342 managed: true,
1343 source: Some("github.com/test/repo".to_string()),
1344 version: Some("v2.0.0".to_string()),
1345 installed_at: "2024-01-01T12:00:00Z".to_string(),
1346 dependency_name: Some("test-dep".to_string()),
1347 }),
1348 },
1349 );
1350
1351 let json = serde_json::to_string_pretty(&config).unwrap();
1353 let deserialized: McpConfig = serde_json::from_str(&json).unwrap();
1354
1355 assert_eq!(deserialized.mcp_servers.len(), 1);
1356 let server = &deserialized.mcp_servers["test"];
1357 assert_eq!(server.command, Some("test-cmd".to_string()));
1358 assert_eq!(server.args.len(), 2);
1359 assert!(server.env.is_some());
1360 assert!(server.agpm_metadata.is_some());
1361
1362 let metadata = server.agpm_metadata.as_ref().unwrap();
1363 assert!(metadata.managed);
1364 assert_eq!(metadata.source, Some("github.com/test/repo".to_string()));
1365 assert_eq!(metadata.version, Some("v2.0.0".to_string()));
1366 }
1367
1368 #[test]
1369 fn test_mcp_server_config_minimal_serialization() {
1370 let config = McpServerConfig {
1371 command: Some("minimal".to_string()),
1372 args: vec![],
1373 env: None,
1374 r#type: None,
1375 url: None,
1376 headers: None,
1377 agpm_metadata: None,
1378 };
1379
1380 let json = serde_json::to_string(&config).unwrap();
1381 let deserialized: McpServerConfig = serde_json::from_str(&json).unwrap();
1382
1383 assert_eq!(deserialized.command, Some("minimal".to_string()));
1384 assert!(deserialized.args.is_empty());
1385 assert!(deserialized.env.is_none());
1386 assert!(deserialized.agpm_metadata.is_none());
1387
1388 assert!(!json.contains(r#""args":[]"#));
1390 }
1391
1392 #[test]
1393 fn test_agpm_metadata_serialization() {
1394 let metadata = AgpmMetadata {
1395 managed: true,
1396 source: Some("test-source".to_string()),
1397 version: None,
1398 installed_at: "2024-01-01T00:00:00Z".to_string(),
1399 dependency_name: Some("test-dep".to_string()),
1400 };
1401
1402 let json = serde_json::to_string(&metadata).unwrap();
1403 let deserialized: AgpmMetadata = serde_json::from_str(&json).unwrap();
1404
1405 assert!(deserialized.managed);
1406 assert_eq!(deserialized.source, Some("test-source".to_string()));
1407 assert_eq!(deserialized.version, None);
1408 assert_eq!(deserialized.installed_at, "2024-01-01T00:00:00Z");
1409 assert_eq!(deserialized.dependency_name, Some("test-dep".to_string()));
1410
1411 assert!(!json.contains(r#""version""#));
1413 }
1414
1415 #[test]
1416 fn test_clean_mcp_servers() {
1417 let temp = tempfile::TempDir::new().unwrap();
1418 setup_project_root(temp.path());
1419
1420 let project_root = temp.path();
1421 let claude_dir = project_root.join(".claude");
1422 let agpm_dir = claude_dir.join("agpm");
1423 let mcp_servers_dir = agpm_dir.join("mcp-servers");
1424 let settings_path = claude_dir.join("settings.local.json");
1425 let mcp_config_path = project_root.join(".mcp.json");
1426
1427 std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1429
1430 let server1_path = mcp_servers_dir.join("server1.json");
1432 let server2_path = mcp_servers_dir.join("server2.json");
1433 let server_config = McpServerConfig {
1434 command: Some("test".to_string()),
1435 args: vec![],
1436 env: None,
1437 r#type: None,
1438 url: None,
1439 headers: None,
1440 agpm_metadata: Some(AgpmMetadata {
1441 managed: true,
1442 source: Some("test-source".to_string()),
1443 version: Some("v1.0.0".to_string()),
1444 installed_at: "2024-01-01T00:00:00Z".to_string(),
1445 dependency_name: Some("test-server".to_string()),
1446 }),
1447 };
1448 crate::utils::write_json_file(&server1_path, &server_config, true).unwrap();
1449 crate::utils::write_json_file(&server2_path, &server_config, true).unwrap();
1450
1451 let mut settings = ClaudeSettings::default();
1453 let mut servers = HashMap::new();
1454
1455 servers.insert(
1457 "agpm-server".to_string(),
1458 McpServerConfig {
1459 command: Some("agpm-cmd".to_string()),
1460 args: vec![],
1461 env: None,
1462 r#type: None,
1463 url: None,
1464 headers: None,
1465 agpm_metadata: Some(AgpmMetadata {
1466 managed: true,
1467 source: Some("test".to_string()),
1468 version: Some("v1.0.0".to_string()),
1469 installed_at: "2024-01-01T00:00:00Z".to_string(),
1470 dependency_name: None,
1471 }),
1472 },
1473 );
1474
1475 servers.insert(
1477 "user-server".to_string(),
1478 McpServerConfig {
1479 command: Some("user-cmd".to_string()),
1480 args: vec![],
1481 env: None,
1482 r#type: None,
1483 url: None,
1484 headers: None,
1485 agpm_metadata: None,
1486 },
1487 );
1488
1489 settings.mcp_servers = Some(servers);
1490 settings.save(&settings_path).unwrap();
1491
1492 let mut mcp_config = McpConfig::default();
1494 mcp_config.mcp_servers.insert(
1495 "agpm-server".to_string(),
1496 McpServerConfig {
1497 command: Some("agpm-cmd".to_string()),
1498 args: vec![],
1499 env: None,
1500 r#type: None,
1501 url: None,
1502 headers: None,
1503 agpm_metadata: Some(AgpmMetadata {
1504 managed: true,
1505 source: Some("test".to_string()),
1506 version: Some("v1.0.0".to_string()),
1507 installed_at: "2024-01-01T00:00:00Z".to_string(),
1508 dependency_name: None,
1509 }),
1510 },
1511 );
1512 mcp_config.mcp_servers.insert(
1513 "user-server".to_string(),
1514 McpServerConfig {
1515 command: Some("user-cmd".to_string()),
1516 args: vec![],
1517 env: None,
1518 r#type: None,
1519 url: None,
1520 headers: None,
1521 agpm_metadata: None,
1522 },
1523 );
1524 mcp_config.save(&mcp_config_path).unwrap();
1525
1526 clean_mcp_servers(project_root).unwrap();
1528
1529 assert!(!server1_path.exists());
1531 assert!(!server2_path.exists());
1532
1533 let updated_mcp_config = McpConfig::load_or_default(&mcp_config_path).unwrap();
1535 assert_eq!(updated_mcp_config.mcp_servers.len(), 1);
1536 assert!(updated_mcp_config.mcp_servers.contains_key("user-server"));
1537 assert!(!updated_mcp_config.mcp_servers.contains_key("agpm-server"));
1538 }
1539
1540 #[test]
1541 fn test_clean_mcp_servers_no_servers() {
1542 let temp = tempfile::TempDir::new().unwrap();
1543 let project_root = temp.path();
1544
1545 let result = clean_mcp_servers(project_root);
1547 assert!(result.is_ok());
1548 }
1549
1550 #[test]
1551 fn test_list_mcp_servers() {
1552 let temp = tempfile::TempDir::new().unwrap();
1553 let project_root = temp.path();
1554 let claude_dir = project_root.join(".claude");
1555 let settings_path = claude_dir.join("settings.local.json");
1556
1557 std::fs::create_dir_all(&claude_dir).unwrap();
1558
1559 let mut settings = ClaudeSettings::default();
1561 let mut servers = HashMap::new();
1562
1563 servers.insert(
1564 "managed-server".to_string(),
1565 McpServerConfig {
1566 command: Some("managed".to_string()),
1567 args: vec![],
1568 env: None,
1569 r#type: None,
1570 url: None,
1571 headers: None,
1572 agpm_metadata: Some(AgpmMetadata {
1573 managed: true,
1574 source: Some("test".to_string()),
1575 version: Some("v2.0.0".to_string()),
1576 installed_at: "2024-01-01T00:00:00Z".to_string(),
1577 dependency_name: None,
1578 }),
1579 },
1580 );
1581
1582 servers.insert(
1583 "user-server".to_string(),
1584 McpServerConfig {
1585 command: Some("user".to_string()),
1586 args: vec![],
1587 env: None,
1588 r#type: None,
1589 url: None,
1590 headers: None,
1591 agpm_metadata: None,
1592 },
1593 );
1594
1595 settings.mcp_servers = Some(servers);
1596 settings.save(&settings_path).unwrap();
1597
1598 let result = list_mcp_servers(project_root);
1600 assert!(result.is_ok());
1601 }
1602
1603 #[test]
1604 fn test_list_mcp_servers_no_file() {
1605 let temp = tempfile::TempDir::new().unwrap();
1606 let project_root = temp.path();
1607
1608 let result = list_mcp_servers(project_root);
1610 assert!(result.is_ok());
1611 }
1612
1613 #[test]
1614 fn test_list_mcp_servers_empty() {
1615 let temp = tempfile::TempDir::new().unwrap();
1616 let project_root = temp.path();
1617 let claude_dir = project_root.join(".claude");
1618 let settings_path = claude_dir.join("settings.local.json");
1619
1620 std::fs::create_dir_all(&claude_dir).unwrap();
1621
1622 let settings = ClaudeSettings::default();
1624 settings.save(&settings_path).unwrap();
1625
1626 let result = list_mcp_servers(project_root);
1628 assert!(result.is_ok());
1629 }
1630
1631 #[test]
1632 fn test_claude_settings_save_backup() {
1633 let temp = tempfile::TempDir::new().unwrap();
1634 setup_project_root(temp.path());
1635
1636 let settings_path = temp.path().join("settings.local.json");
1637 let backup_path = temp
1638 .path()
1639 .join(".agpm")
1640 .join("backups")
1641 .join("claude-code")
1642 .join("settings.local.json");
1643
1644 let settings1 = ClaudeSettings::default();
1646 settings1.save(&settings_path).unwrap();
1647 assert!(settings_path.exists());
1648 assert!(!backup_path.exists());
1649
1650 let settings2 = ClaudeSettings {
1652 hooks: Some(serde_json::json!({"test": "hook"})),
1653 ..Default::default()
1654 };
1655 settings2.save(&settings_path).unwrap();
1656
1657 assert!(backup_path.exists());
1659
1660 let backup_content: ClaudeSettings = crate::utils::read_json_file(&backup_path).unwrap();
1662 assert!(backup_content.hooks.is_none());
1663
1664 let main_content: ClaudeSettings = crate::utils::read_json_file(&settings_path).unwrap();
1666 assert!(main_content.hooks.is_some());
1667 }
1668
1669 #[test]
1670 fn test_mcp_config_save_backup() {
1671 let temp = tempfile::TempDir::new().unwrap();
1672 setup_project_root(temp.path());
1673
1674 let config_path = temp.path().join(".mcp.json");
1675 let backup_path =
1676 temp.path().join(".agpm").join("backups").join("claude-code").join(".mcp.json");
1677
1678 let config1 = McpConfig::default();
1680 config1.save(&config_path).unwrap();
1681 assert!(config_path.exists());
1682 assert!(!backup_path.exists());
1683
1684 let mut config2 = McpConfig::default();
1686 config2.mcp_servers.insert(
1687 "test".to_string(),
1688 McpServerConfig {
1689 command: Some("test-cmd".to_string()),
1690 args: vec![],
1691 env: None,
1692 r#type: None,
1693 url: None,
1694 headers: None,
1695 agpm_metadata: None,
1696 },
1697 );
1698 config2.save(&config_path).unwrap();
1699
1700 assert!(backup_path.exists());
1702
1703 let backup_content: McpConfig = crate::utils::read_json_file(&backup_path).unwrap();
1705 assert!(backup_content.mcp_servers.is_empty());
1706
1707 let main_content: McpConfig = crate::utils::read_json_file(&config_path).unwrap();
1709 assert_eq!(main_content.mcp_servers.len(), 1);
1710 }
1711
1712 #[test]
1713 fn test_backup_fails_without_project_root() {
1714 let temp = tempfile::TempDir::new().unwrap();
1716 let settings_path = temp.path().join("settings.local.json");
1719
1720 fs::write(&settings_path, r#"{"test": "value"}"#).unwrap();
1722
1723 let settings = ClaudeSettings::default();
1724 let result = settings.save(&settings_path);
1725
1726 assert!(result.is_err());
1728 let error_msg = result.unwrap_err().to_string();
1729 assert!(
1730 error_msg.contains("Failed to find project root") || error_msg.contains("agpm.toml")
1731 );
1732 }
1733
1734 #[test]
1735 fn test_update_mcp_servers_preserves_user_servers() {
1736 let temp = tempfile::TempDir::new().unwrap();
1737 let agpm_dir = temp.path().join(".claude").join("agpm");
1738 let mcp_servers_dir = agpm_dir.join("mcp-servers");
1739 std::fs::create_dir_all(&mcp_servers_dir).unwrap();
1740
1741 let server1 = McpServerConfig {
1743 command: Some("server1".to_string()),
1744 args: vec!["arg1".to_string()],
1745 env: None,
1746 r#type: None,
1747 url: None,
1748 headers: None,
1749 agpm_metadata: Some(AgpmMetadata {
1750 managed: true,
1751 source: Some("source1".to_string()),
1752 version: Some("v1.0.0".to_string()),
1753 installed_at: "2024-01-01T00:00:00Z".to_string(),
1754 dependency_name: None,
1755 }),
1756 };
1757 crate::utils::write_json_file(&mcp_servers_dir.join("server1.json"), &server1, true)
1758 .unwrap();
1759
1760 let mut settings = ClaudeSettings::default();
1762 let mut servers = HashMap::new();
1763 servers.insert(
1764 "user-server".to_string(),
1765 McpServerConfig {
1766 command: Some("user".to_string()),
1767 args: vec![],
1768 env: None,
1769 r#type: None,
1770 url: None,
1771 headers: None,
1772 agpm_metadata: None,
1773 },
1774 );
1775 settings.mcp_servers = Some(servers);
1776
1777 settings.update_mcp_servers(&mcp_servers_dir).unwrap();
1779
1780 let servers = settings.mcp_servers.as_ref().unwrap();
1782 assert_eq!(servers.len(), 2);
1783 assert!(servers.contains_key("user-server"));
1784 assert!(servers.contains_key("server1"));
1785
1786 let server1_config = servers.get("server1").unwrap();
1788 assert_eq!(server1_config.command, Some("server1".to_string()));
1789 assert_eq!(server1_config.args, vec!["arg1"]);
1790 }
1791
1792 #[test]
1793 fn test_update_mcp_servers_nonexistent_dir() {
1794 let temp = tempfile::TempDir::new().unwrap();
1795 let nonexistent_dir = temp.path().join("nonexistent");
1796
1797 let mut settings = ClaudeSettings::default();
1798 let result = settings.update_mcp_servers(&nonexistent_dir);
1799 assert!(result.is_ok());
1800 }
1801
1802 #[test]
1803 fn test_mcp_config_handles_extra_fields() {
1804 let json_str = r#"{
1806 "mcpServers": {
1807 "test": {
1808 "command": "test",
1809 "args": []
1810 }
1811 },
1812 "customField": "value",
1813 "anotherField": {
1814 "nested": true
1815 }
1816 }"#;
1817
1818 let temp = tempdir().unwrap();
1819 let config_path = temp.path().join(".mcp.json");
1820 std::fs::write(&config_path, json_str).unwrap();
1821
1822 let config = McpConfig::load_or_default(&config_path).unwrap();
1824 assert!(config.mcp_servers.contains_key("test"));
1825 assert_eq!(config.mcp_servers.len(), 1);
1826 }
1827}