1use anyhow::{Context, Result};
28use colored::Colorize;
29use std::io::{self, IsTerminal, Write};
30use std::path::{Path, PathBuf};
31use tokio::io::{AsyncBufReadExt, BufReader};
32
33use crate::manifest::{Manifest, find_manifest};
34
35pub trait CommandExecutor: Sized {
37 fn execute(self) -> impl std::future::Future<Output = Result<()>> + Send
39 where
40 Self: Send,
41 {
42 async move {
43 let manifest_path = if let Ok(path) = find_manifest() {
44 path
45 } else {
46 match handle_legacy_ccpm_migration(None, false).await {
50 Ok(Some(path)) => path,
51 Ok(None) => {
52 return Err(anyhow::anyhow!(
53 "No agpm.toml found in current directory or any parent directory. \
54 Run 'agpm init' to create a new project."
55 ));
56 }
57 Err(e) => return Err(e),
58 }
59 };
60 self.execute_from_path(manifest_path).await
61 }
62 }
63
64 fn execute_from_path(
66 self,
67 manifest_path: PathBuf,
68 ) -> impl std::future::Future<Output = Result<()>> + Send;
69}
70
71#[derive(Debug)]
73pub struct CommandContext {
74 pub manifest: Manifest,
76 pub manifest_path: PathBuf,
78 pub project_dir: PathBuf,
80 pub lockfile_path: PathBuf,
82}
83
84impl CommandContext {
85 pub fn new(manifest: Manifest, project_dir: PathBuf) -> Result<Self> {
87 let lockfile_path = project_dir.join("agpm.lock");
88 Ok(Self {
89 manifest,
90 manifest_path: project_dir.join("agpm.toml"),
91 project_dir,
92 lockfile_path,
93 })
94 }
95
96 pub fn from_manifest_path(manifest_path: impl AsRef<Path>) -> Result<Self> {
101 let manifest_path = manifest_path.as_ref();
102
103 if !manifest_path.exists() {
104 return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
105 }
106
107 let project_dir = manifest_path
108 .parent()
109 .ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?
110 .to_path_buf();
111
112 let manifest = Manifest::load(manifest_path).with_context(|| {
113 format!("Failed to parse manifest file: {}", manifest_path.display())
114 })?;
115
116 let lockfile_path = project_dir.join("agpm.lock");
117
118 Ok(Self {
119 manifest,
120 manifest_path: manifest_path.to_path_buf(),
121 project_dir,
122 lockfile_path,
123 })
124 }
125
126 pub fn reload_manifest(&mut self) -> Result<()> {
134 self.manifest = Manifest::load(&self.manifest_path).with_context(|| {
135 format!("Failed to reload manifest file: {}", self.manifest_path.display())
136 })?;
137 Ok(())
138 }
139
140 pub fn load_lockfile(&self) -> Result<Option<crate::lockfile::LockFile>> {
145 if self.lockfile_path.exists() {
146 let lockfile =
147 crate::lockfile::LockFile::load(&self.lockfile_path).with_context(|| {
148 format!("Failed to load lockfile: {}", self.lockfile_path.display())
149 })?;
150 Ok(Some(lockfile))
151 } else {
152 Ok(None)
153 }
154 }
155
156 pub fn load_lockfile_with_regeneration(
199 &self,
200 can_regenerate: bool,
201 operation_name: &str,
202 ) -> Result<Option<crate::lockfile::LockFile>> {
203 if !self.lockfile_path.exists() {
205 return Ok(None);
206 }
207
208 match crate::lockfile::LockFile::load(&self.lockfile_path) {
210 Ok(mut lockfile) => {
211 if let Ok(Some(private_lock)) =
213 crate::lockfile::PrivateLockFile::load(&self.project_dir)
214 {
215 lockfile.merge_private(&private_lock);
216 }
217 Ok(Some(lockfile))
220 }
221 Err(e) => {
222 let error_msg = e.to_string();
224 let can_auto_recover = can_regenerate
225 && (error_msg.contains("Invalid TOML syntax")
226 || error_msg.contains("Lockfile version")
227 || error_msg.contains("missing field")
228 || error_msg.contains("invalid type")
229 || error_msg.contains("expected"));
230
231 if !can_auto_recover {
232 return Err(e);
234 }
235
236 let backup_path = self.lockfile_path.with_extension("lock.invalid");
238
239 let regenerate_message = format!(
241 "The lockfile appears to be invalid or corrupted.\n\n\
242 Error: {}\n\n\
243 Note: The lockfile format is not yet stable as this is beta software.\n\n\
244 The invalid lockfile will be backed up to: {}",
245 error_msg,
246 backup_path.display()
247 );
248
249 if io::stdin().is_terminal() {
251 println!("{}", regenerate_message);
253 print!("Would you like to regenerate the lockfile automatically? [Y/n] ");
254 io::stdout().flush().unwrap();
256
257 let mut input = String::new();
258 match io::stdin().read_line(&mut input) {
259 Ok(_) => {
260 let response = input.trim().to_lowercase();
261 if response.is_empty() || response == "y" || response == "yes" {
262 self.backup_and_regenerate_lockfile(&backup_path, operation_name)?;
264 Ok(None) } else {
266 Err(crate::core::AgpmError::InvalidLockfileError {
268 file: self.lockfile_path.display().to_string(),
269 reason: format!(
270 "{} (User declined automatic regeneration)",
271 error_msg
272 ),
273 can_regenerate: true,
274 }
275 .into())
276 }
277 }
278 Err(_) => {
279 Err(self.create_non_interactive_error(&error_msg, operation_name))
281 }
282 }
283 } else {
284 Err(self.create_non_interactive_error(&error_msg, operation_name))
286 }
287 }
288 }
289 }
290
291 fn backup_and_regenerate_lockfile(
293 &self,
294 backup_path: &Path,
295 operation_name: &str,
296 ) -> Result<()> {
297 if let Err(e) = std::fs::copy(&self.lockfile_path, backup_path) {
299 eprintln!("Warning: Failed to backup invalid lockfile: {}", e);
300 } else {
301 println!("✓ Backed up invalid lockfile to: {}", backup_path.display());
302 }
303
304 if let Err(e) = std::fs::remove_file(&self.lockfile_path) {
306 return Err(anyhow::anyhow!("Failed to remove invalid lockfile: {}", e));
307 }
308
309 println!("✓ Removed invalid lockfile");
310 println!("Note: Run 'agpm install' to regenerate the lockfile");
311
312 if operation_name != "install" {
314 println!("Alternatively, run 'agpm {} --regenerate' if available", operation_name);
315 }
316
317 Ok(())
318 }
319
320 fn create_non_interactive_error(
322 &self,
323 error_msg: &str,
324 _operation_name: &str,
325 ) -> anyhow::Error {
326 let backup_path = self.lockfile_path.with_extension("lock.invalid");
327
328 crate::core::AgpmError::InvalidLockfileError {
329 file: self.lockfile_path.display().to_string(),
330 reason: format!(
331 "{}\n\n\
332 To fix this issue:\n\
333 1. Backup the invalid lockfile: cp agpm.lock {}\n\
334 2. Remove the invalid lockfile: rm agpm.lock\n\
335 3. Regenerate it: agpm install\n\n\
336 Note: The lockfile format is not yet stable as this is beta software.",
337 error_msg,
338 backup_path.display()
339 ),
340 can_regenerate: true,
341 }
342 .into()
343 }
344
345 pub fn save_lockfile(&self, lockfile: &crate::lockfile::LockFile) -> Result<()> {
350 lockfile
351 .save(&self.lockfile_path)
352 .with_context(|| format!("Failed to save lockfile: {}", self.lockfile_path.display()))
353 }
354}
355
356pub async fn handle_legacy_ccpm_migration(
408 from_dir: Option<PathBuf>,
409 yes: bool,
410) -> Result<Option<PathBuf>> {
411 let current_dir = match from_dir {
412 Some(dir) => dir,
413 None => std::env::current_dir()?,
414 };
415 let legacy_dir = find_legacy_ccpm_directory(¤t_dir);
416
417 let Some(dir) = legacy_dir else {
418 return Ok(None);
419 };
420
421 if !yes && !std::io::stdin().is_terminal() {
423 eprintln!("{}", "Legacy CCPM files detected (non-interactive mode).".yellow());
425 eprintln!(
426 "Run {} to migrate manually, or use --yes to auto-accept.",
427 format!("agpm migrate --path {}", dir.display()).cyan()
428 );
429 return Ok(None);
430 }
431
432 let ccpm_toml = dir.join("ccpm.toml");
434 let ccpm_lock = dir.join("ccpm.lock");
435
436 let mut files = Vec::new();
437 if ccpm_toml.exists() {
438 files.push("ccpm.toml");
439 }
440 if ccpm_lock.exists() {
441 files.push("ccpm.lock");
442 }
443
444 let files_str = files.join(" and ");
445
446 println!("{}", "Legacy CCPM files detected!".yellow().bold());
447 println!("{} {} found in {}", "→".cyan(), files_str, dir.display());
448 println!();
449
450 let should_migrate = if yes {
452 true
453 } else {
454 print!("{} ", "Would you like to migrate to AGPM now? [Y/n]:".green());
456 io::stdout().flush()?;
457
458 let mut reader = BufReader::new(tokio::io::stdin());
460 let mut response = String::new();
461 reader.read_line(&mut response).await?;
462 let response = response.trim().to_lowercase();
463 response.is_empty() || response == "y" || response == "yes"
464 };
465
466 if should_migrate {
467 println!();
468 println!("{}", "🚀 Starting migration...".cyan());
469
470 let migrate_cmd = super::migrate::MigrateCommand::new(Some(dir.clone()), false, false);
472
473 migrate_cmd.execute().await?;
474
475 Ok(Some(dir.join("agpm.toml")))
477 } else {
478 println!();
479 println!("{}", "Migration cancelled.".yellow());
480 println!(
481 "Run {} to migrate manually.",
482 format!("agpm migrate --path {}", dir.display()).cyan()
483 );
484 Ok(None)
485 }
486}
487
488pub async fn handle_legacy_format_migration(project_dir: &Path, yes: bool) -> Result<bool> {
531 use super::migrate::{detect_old_format, run_format_migration};
532
533 let detection = detect_old_format(project_dir);
534
535 if !detection.needs_migration() {
536 return Ok(false);
537 }
538
539 if !yes && !std::io::stdin().is_terminal() {
541 eprintln!("{}", "Legacy AGPM format detected (non-interactive mode).".yellow());
543 eprintln!(
544 "Run {} to migrate manually, or use --yes to auto-accept.",
545 format!("agpm migrate --path {}", project_dir.display()).cyan()
546 );
547 return Ok(false);
548 }
549
550 println!("{}", "Legacy AGPM format detected!".yellow().bold());
552
553 if !detection.old_resource_paths.is_empty() {
554 println!(
555 "\n{} Found {} resources at old paths:",
556 "→".cyan(),
557 detection.old_resource_paths.len()
558 );
559 for path in &detection.old_resource_paths {
560 let rel = path.strip_prefix(project_dir).unwrap_or(path);
561 println!(" • {}", rel.display());
562 }
563 }
564
565 if detection.has_managed_gitignore_section {
566 println!("\n{} Found AGPM/CCPM managed section in .gitignore", "→".cyan());
567 }
568
569 println!();
570 println!(
571 "{}",
572 "The new format uses agpm/ subdirectories for easier gitignore management.".dimmed()
573 );
574 println!();
575
576 let should_migrate = if yes {
578 true
579 } else {
580 print!("{} ", "Would you like to migrate to the new format now? [Y/n]:".green());
582 io::stdout().flush()?;
583
584 let mut reader = BufReader::new(tokio::io::stdin());
586 let mut response = String::new();
587 reader.read_line(&mut response).await?;
588 let response = response.trim().to_lowercase();
589 response.is_empty() || response == "y" || response == "yes"
590 };
591
592 if should_migrate {
593 println!();
594 run_format_migration(project_dir).await?;
595 Ok(true)
596 } else {
597 println!();
598 println!("{}", "Migration cancelled.".yellow());
599 println!(
600 "Run {} to migrate manually.",
601 format!("agpm migrate --path {}", project_dir.display()).cyan()
602 );
603 Ok(false)
604 }
605}
606
607#[must_use]
619pub fn check_for_legacy_ccpm_files() -> Option<String> {
620 check_for_legacy_ccpm_files_from(std::env::current_dir().ok()?)
621}
622
623fn find_legacy_ccpm_directory(start_dir: &Path) -> Option<PathBuf> {
633 let mut dir = start_dir;
634
635 loop {
636 let ccpm_toml = dir.join("ccpm.toml");
637 let ccpm_lock = dir.join("ccpm.lock");
638
639 if ccpm_toml.exists() || ccpm_lock.exists() {
640 return Some(dir.to_path_buf());
641 }
642
643 dir = dir.parent()?;
644 }
645}
646
647fn check_for_legacy_ccpm_files_from(start_dir: PathBuf) -> Option<String> {
652 let current = start_dir;
653 let mut dir = current.as_path();
654
655 loop {
656 let ccpm_toml = dir.join("ccpm.toml");
657 let ccpm_lock = dir.join("ccpm.lock");
658
659 if ccpm_toml.exists() || ccpm_lock.exists() {
660 let mut files = Vec::new();
661 if ccpm_toml.exists() {
662 files.push("ccpm.toml");
663 }
664 if ccpm_lock.exists() {
665 files.push("ccpm.lock");
666 }
667
668 let files_str = files.join(" and ");
669 let location = if dir == current {
670 "current directory".to_string()
671 } else {
672 format!("parent directory: {}", dir.display())
673 };
674
675 return Some(format!(
676 "{}\n\n{} {} found in {}.\n{}\n {}\n\n{}",
677 "Legacy CCPM files detected!".yellow().bold(),
678 "→".cyan(),
679 files_str,
680 location,
681 "Run the migration command to upgrade:".yellow(),
682 format!("agpm migrate --path {}", dir.display()).cyan().bold(),
683 "Or run 'agpm init' to create a new AGPM project.".dimmed()
684 ));
685 }
686
687 dir = dir.parent()?;
688 }
689}
690
691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
709pub enum OperationMode {
710 Install,
712 Update,
714}
715
716pub fn display_dry_run_results(
771 new_lockfile: &crate::lockfile::LockFile,
772 existing_lockfile: Option<&crate::lockfile::LockFile>,
773 quiet: bool,
774) -> Result<()> {
775 let (new_resources, updated_resources, unchanged_count) =
777 categorize_resource_changes(new_lockfile, existing_lockfile);
778
779 let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
781 display_dry_run_output(&new_resources, &updated_resources, unchanged_count, quiet);
782
783 if has_changes {
785 Err(anyhow::anyhow!("Dry-run detected changes (exit 1)"))
786 } else {
787 Ok(())
788 }
789}
790
791#[derive(Debug, Clone)]
793struct NewResource {
794 resource_type: String,
795 name: String,
796 version: String,
797}
798
799#[derive(Debug, Clone)]
801struct UpdatedResource {
802 resource_type: String,
803 name: String,
804 old_version: String,
805 new_version: String,
806}
807
808fn categorize_resource_changes(
813 new_lockfile: &crate::lockfile::LockFile,
814 existing_lockfile: Option<&crate::lockfile::LockFile>,
815) -> (Vec<NewResource>, Vec<UpdatedResource>, usize) {
816 use crate::core::resource_iterator::ResourceIterator;
817
818 let mut new_resources = Vec::new();
819 let mut updated_resources = Vec::new();
820 let mut unchanged_count = 0;
821
822 if let Some(existing) = existing_lockfile {
824 ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
825 if let Some((_, old_entry)) = ResourceIterator::find_resource_by_name_and_source(
827 existing,
828 &new_entry.name,
829 new_entry.source.as_deref(),
830 ) {
831 if old_entry.resolved_commit == new_entry.resolved_commit {
833 unchanged_count += 1;
834 } else {
835 let old_version =
836 old_entry.version.clone().unwrap_or_else(|| "latest".to_string());
837 let new_version =
838 new_entry.version.clone().unwrap_or_else(|| "latest".to_string());
839 updated_resources.push(UpdatedResource {
840 resource_type: resource_type.to_string(),
841 name: new_entry.name.clone(),
842 old_version,
843 new_version,
844 });
845 }
846 } else {
847 new_resources.push(NewResource {
849 resource_type: resource_type.to_string(),
850 name: new_entry.name.clone(),
851 version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
852 });
853 }
854 });
855 } else {
856 ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
858 new_resources.push(NewResource {
859 resource_type: resource_type.to_string(),
860 name: new_entry.name.clone(),
861 version: new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
862 });
863 });
864 }
865
866 (new_resources, updated_resources, unchanged_count)
867}
868
869fn display_dry_run_output(
874 new_resources: &[NewResource],
875 updated_resources: &[UpdatedResource],
876 unchanged_count: usize,
877 quiet: bool,
878) {
879 if quiet {
880 return;
881 }
882
883 let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
884
885 if has_changes {
886 println!("{}", "Dry run - the following changes would be made:".yellow());
887 println!();
888
889 if !new_resources.is_empty() {
890 println!("{}", "New resources:".green().bold());
891 for resource in new_resources {
892 println!(
893 " {} {} ({})",
894 "+".green(),
895 resource.name.cyan(),
896 format!("{} {}", resource.resource_type, resource.version).dimmed()
897 );
898 }
899 println!();
900 }
901
902 if !updated_resources.is_empty() {
903 println!("{}", "Updated resources:".yellow().bold());
904 for resource in updated_resources {
905 print!(
906 " {} {} {} → ",
907 "~".yellow(),
908 resource.name.cyan(),
909 resource.old_version.yellow()
910 );
911 println!("{} ({})", resource.new_version.green(), resource.resource_type.dimmed());
912 }
913 println!();
914 }
915
916 if unchanged_count > 0 {
917 println!("{}", format!("{unchanged_count} unchanged resources").dimmed());
918 }
919
920 println!();
921 println!(
922 "{}",
923 format!(
924 "Total: {} new, {} updated, {} unchanged",
925 new_resources.len(),
926 updated_resources.len(),
927 unchanged_count
928 )
929 .bold()
930 );
931 println!();
932 println!("{}", "No files were modified (dry-run mode)".yellow());
933 } else {
934 println!("✓ {}", "No changes would be made".green());
935 }
936}
937
938pub fn display_no_changes(mode: OperationMode, quiet: bool) {
957 if quiet {
958 return;
959 }
960
961 match mode {
962 OperationMode::Install => println!("No dependencies to install"),
963 OperationMode::Update => println!("All dependencies are up to date!"),
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use tempfile::TempDir;
971
972 #[test]
973 fn test_command_context_from_manifest_path() {
974 let temp_dir = TempDir::new().unwrap();
975 let manifest_path = temp_dir.path().join("agpm.toml");
976
977 std::fs::write(
979 &manifest_path,
980 r#"
981[sources]
982test = "https://github.com/test/repo.git"
983
984[agents]
985"#,
986 )
987 .unwrap();
988
989 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
990
991 assert_eq!(context.manifest_path, manifest_path);
992 assert_eq!(context.project_dir, temp_dir.path());
993 assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
994 assert!(context.manifest.sources.contains_key("test"));
995 }
996
997 #[test]
998 fn test_command_context_missing_manifest() {
999 let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
1000 assert!(result.is_err());
1001 assert!(result.unwrap_err().to_string().contains("not found"));
1002 }
1003
1004 #[test]
1005 fn test_command_context_invalid_manifest() {
1006 let temp_dir = TempDir::new().unwrap();
1007 let manifest_path = temp_dir.path().join("agpm.toml");
1008
1009 std::fs::write(&manifest_path, "invalid toml {{").unwrap();
1011
1012 let result = CommandContext::from_manifest_path(&manifest_path);
1013 assert!(result.is_err());
1014 assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
1015 }
1016
1017 #[test]
1018 fn test_load_lockfile_exists() {
1019 let temp_dir = TempDir::new().unwrap();
1020 let manifest_path = temp_dir.path().join("agpm.toml");
1021 let lockfile_path = temp_dir.path().join("agpm.lock");
1022
1023 std::fs::write(&manifest_path, "[sources]\n").unwrap();
1025 std::fs::write(
1026 &lockfile_path,
1027 r#"
1028version = 1
1029
1030[[sources]]
1031name = "test"
1032url = "https://github.com/test/repo.git"
1033commit = "abc123"
1034fetched_at = "2024-01-01T00:00:00Z"
1035"#,
1036 )
1037 .unwrap();
1038
1039 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1040 let lockfile = context.load_lockfile().unwrap();
1041
1042 assert!(lockfile.is_some());
1043 let lockfile = lockfile.unwrap();
1044 assert_eq!(lockfile.sources.len(), 1);
1045 assert_eq!(lockfile.sources[0].name, "test");
1046 }
1047
1048 #[test]
1049 fn test_load_lockfile_not_exists() {
1050 let temp_dir = TempDir::new().unwrap();
1051 let manifest_path = temp_dir.path().join("agpm.toml");
1052
1053 std::fs::write(&manifest_path, "[sources]\n").unwrap();
1054
1055 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1056 let lockfile = context.load_lockfile().unwrap();
1057
1058 assert!(lockfile.is_none());
1059 }
1060
1061 #[test]
1062 fn test_save_lockfile() {
1063 let temp_dir = TempDir::new().unwrap();
1064 let manifest_path = temp_dir.path().join("agpm.toml");
1065
1066 std::fs::write(&manifest_path, "[sources]\n").unwrap();
1067
1068 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1069
1070 let lockfile = crate::lockfile::LockFile {
1071 version: 1,
1072 sources: vec![],
1073 agents: vec![],
1074 snippets: vec![],
1075 commands: vec![],
1076 scripts: vec![],
1077 hooks: vec![],
1078 mcp_servers: vec![],
1079 skills: vec![],
1080 manifest_hash: None,
1081 has_mutable_deps: None,
1082 resource_count: None,
1083 };
1084
1085 context.save_lockfile(&lockfile).unwrap();
1086
1087 assert!(context.lockfile_path.exists());
1088 let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
1089 assert!(saved_content.contains("version = 1"));
1090 }
1091
1092 #[test]
1093 fn test_check_for_legacy_ccpm_no_files() {
1094 let temp_dir = TempDir::new().unwrap();
1095 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1096 assert!(result.is_none());
1097 }
1098
1099 #[test]
1100 fn test_check_for_legacy_ccpm_toml_only() {
1101 let temp_dir = TempDir::new().unwrap();
1102 std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1103
1104 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1105 assert!(result.is_some());
1106 let msg = result.unwrap();
1107 assert!(msg.contains("Legacy CCPM files detected"));
1108 assert!(msg.contains("ccpm.toml"));
1109 assert!(msg.contains("agpm migrate"));
1110 }
1111
1112 #[test]
1113 fn test_check_for_legacy_ccpm_lock_only() {
1114 let temp_dir = TempDir::new().unwrap();
1115 std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1116
1117 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1118 assert!(result.is_some());
1119 let msg = result.unwrap();
1120 assert!(msg.contains("ccpm.lock"));
1121 }
1122
1123 #[test]
1124 fn test_check_for_legacy_ccpm_both_files() {
1125 let temp_dir = TempDir::new().unwrap();
1126 std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1127 std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1128
1129 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1130 assert!(result.is_some());
1131 let msg = result.unwrap();
1132 assert!(msg.contains("ccpm.toml and ccpm.lock"));
1133 }
1134
1135 #[test]
1136 fn test_find_legacy_ccpm_directory_no_files() {
1137 let temp_dir = TempDir::new().unwrap();
1138 let result = find_legacy_ccpm_directory(temp_dir.path());
1139 assert!(result.is_none());
1140 }
1141
1142 #[test]
1143 fn test_find_legacy_ccpm_directory_in_current_dir() {
1144 let temp_dir = TempDir::new().unwrap();
1145 std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1146
1147 let result = find_legacy_ccpm_directory(temp_dir.path());
1148 assert!(result.is_some());
1149 assert_eq!(result.unwrap(), temp_dir.path());
1150 }
1151
1152 #[test]
1153 fn test_find_legacy_ccpm_directory_in_parent() {
1154 let temp_dir = TempDir::new().unwrap();
1155 let parent = temp_dir.path();
1156 let child = parent.join("subdir");
1157 std::fs::create_dir(&child).unwrap();
1158
1159 std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
1161
1162 let result = find_legacy_ccpm_directory(&child);
1164 assert!(result.is_some());
1165 assert_eq!(result.unwrap(), parent);
1166 }
1167
1168 #[test]
1169 fn test_find_legacy_ccpm_directory_finds_lock_file() {
1170 let temp_dir = TempDir::new().unwrap();
1171 std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1172
1173 let result = find_legacy_ccpm_directory(temp_dir.path());
1174 assert!(result.is_some());
1175 assert_eq!(result.unwrap(), temp_dir.path());
1176 }
1177
1178 #[tokio::test]
1179 async fn test_handle_legacy_ccpm_migration_no_files() -> Result<()> {
1180 let temp_dir = TempDir::new()?;
1181
1182 let result = handle_legacy_ccpm_migration(Some(temp_dir.path().to_path_buf()), false).await;
1184
1185 assert!(result?.is_none());
1186 Ok(())
1187 }
1188
1189 #[cfg(test)]
1190 mod lockfile_regeneration_tests {
1191 use super::*;
1192 use crate::manifest::Manifest;
1193 use tempfile::TempDir;
1194
1195 #[test]
1196 fn test_load_lockfile_with_regeneration_valid_lockfile() {
1197 let temp_dir = TempDir::new().unwrap();
1198 let project_dir = temp_dir.path();
1199 let manifest_path = project_dir.join("agpm.toml");
1200 let lockfile_path = project_dir.join("agpm.lock");
1201
1202 let manifest_content = r#"[sources]
1204example = "https://github.com/example/repo.git"
1205
1206[agents]
1207test = { source = "example", path = "test.md", version = "v1.0.0" }
1208"#;
1209 std::fs::write(&manifest_path, manifest_content).unwrap();
1210
1211 let lockfile_content = r#"version = 1
1213
1214[[sources]]
1215name = "example"
1216url = "https://github.com/example/repo.git"
1217commit = "abc123def456789012345678901234567890abcd"
1218fetched_at = "2024-01-01T00:00:00Z"
1219
1220[[agents]]
1221name = "test"
1222source = "example"
1223path = "test.md"
1224version = "v1.0.0"
1225resolved_commit = "abc123def456789012345678901234567890abcd"
1226checksum = "sha256:examplechecksum"
1227installed_at = ".claude/agents/test.md"
1228"#;
1229 std::fs::write(&lockfile_path, lockfile_content).unwrap();
1230
1231 let manifest = Manifest::load(&manifest_path).unwrap();
1233 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1234
1235 let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1236 assert!(result.is_some());
1237 }
1238
1239 #[test]
1240 fn test_load_lockfile_with_regeneration_invalid_toml() {
1241 let temp_dir = TempDir::new().unwrap();
1242 let project_dir = temp_dir.path();
1243 let manifest_path = project_dir.join("agpm.toml");
1244 let lockfile_path = project_dir.join("agpm.lock");
1245
1246 let manifest_content = r#"[sources]
1248example = "https://github.com/example/repo.git"
1249"#;
1250 std::fs::write(&manifest_path, manifest_content).unwrap();
1251
1252 std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1254
1255 let manifest = Manifest::load(&manifest_path).unwrap();
1257 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1258
1259 let result = ctx.load_lockfile_with_regeneration(true, "test");
1261 assert!(result.is_err());
1262
1263 let error_msg = result.unwrap_err().to_string();
1264 assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
1265 assert!(error_msg.contains("beta software"));
1266 assert!(error_msg.contains("cp agpm.lock"));
1267 }
1268
1269 #[test]
1270 fn test_load_lockfile_with_regeneration_missing_lockfile() {
1271 let temp_dir = TempDir::new().unwrap();
1272 let project_dir = temp_dir.path();
1273 let manifest_path = project_dir.join("agpm.toml");
1274
1275 let manifest_content = r#"[sources]
1277example = "https://github.com/example/repo.git"
1278"#;
1279 std::fs::write(&manifest_path, manifest_content).unwrap();
1280
1281 let manifest = Manifest::load(&manifest_path).unwrap();
1283 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1284
1285 let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1286 assert!(result.is_none()); }
1288
1289 #[test]
1290 fn test_load_lockfile_with_regeneration_version_incompatibility() {
1291 let temp_dir = TempDir::new().unwrap();
1292 let project_dir = temp_dir.path();
1293 let manifest_path = project_dir.join("agpm.toml");
1294 let lockfile_path = project_dir.join("agpm.lock");
1295
1296 let manifest_content = r#"[sources]
1298example = "https://github.com/example/repo.git"
1299"#;
1300 std::fs::write(&manifest_path, manifest_content).unwrap();
1301
1302 let lockfile_content = r#"version = 999
1304
1305[[sources]]
1306name = "example"
1307url = "https://github.com/example/repo.git"
1308commit = "abc123def456789012345678901234567890abcd"
1309fetched_at = "2024-01-01T00:00:00Z"
1310"#;
1311 std::fs::write(&lockfile_path, lockfile_content).unwrap();
1312
1313 let manifest = Manifest::load(&manifest_path).unwrap();
1315 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1316
1317 let result = ctx.load_lockfile_with_regeneration(true, "test");
1318 assert!(result.is_err());
1319
1320 let error_msg = result.unwrap_err().to_string();
1321 assert!(error_msg.contains("version") || error_msg.contains("newer"));
1322 }
1323
1324 #[test]
1325 fn test_load_lockfile_with_regeneration_cannot_regenerate() {
1326 let temp_dir = TempDir::new().unwrap();
1327 let project_dir = temp_dir.path();
1328 let manifest_path = project_dir.join("agpm.toml");
1329 let lockfile_path = project_dir.join("agpm.lock");
1330
1331 let manifest_content = r#"[sources]
1333example = "https://github.com/example/repo.git"
1334"#;
1335 std::fs::write(&manifest_path, manifest_content).unwrap();
1336
1337 std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1339
1340 let manifest = Manifest::load(&manifest_path).unwrap();
1342 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1343
1344 let result = ctx.load_lockfile_with_regeneration(false, "test");
1345 assert!(result.is_err());
1346
1347 let error_msg = result.unwrap_err().to_string();
1349 assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
1350 assert!(
1351 error_msg.contains("Failed to load lockfile")
1352 || error_msg.contains("Invalid TOML syntax")
1353 );
1354 }
1355
1356 #[test]
1357 fn test_backup_and_regenerate_lockfile() {
1358 let temp_dir = TempDir::new().unwrap();
1359 let project_dir = temp_dir.path();
1360 let manifest_path = project_dir.join("agpm.toml");
1361 let lockfile_path = project_dir.join("agpm.lock");
1362
1363 let manifest_content = r#"[sources]
1365example = "https://github.com/example/repo.git"
1366"#;
1367 std::fs::write(&manifest_path, manifest_content).unwrap();
1368
1369 std::fs::write(&lockfile_path, "invalid content").unwrap();
1371
1372 let manifest = Manifest::load(&manifest_path).unwrap();
1374 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1375
1376 let backup_path = lockfile_path.with_extension("lock.invalid");
1377
1378 ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
1380
1381 assert!(backup_path.exists());
1383 assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
1384
1385 assert!(!lockfile_path.exists());
1387 }
1388
1389 #[test]
1390 fn test_create_non_interactive_error() {
1391 let temp_dir = TempDir::new().unwrap();
1392 let project_dir = temp_dir.path();
1393 let manifest_path = project_dir.join("agpm.toml");
1394
1395 let manifest_content = r#"[sources]
1397example = "https://github.com/example/repo.git"
1398"#;
1399 std::fs::write(&manifest_path, manifest_content).unwrap();
1400
1401 let manifest = Manifest::load(&manifest_path).unwrap();
1403 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1404
1405 let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
1406 let error_msg = error.to_string();
1407
1408 assert!(error_msg.contains("Invalid TOML syntax"));
1409 assert!(error_msg.contains("beta software"));
1410 assert!(error_msg.contains("cp agpm.lock"));
1411 assert!(error_msg.contains("rm agpm.lock"));
1412 assert!(error_msg.contains("agpm install"));
1413 }
1414 }
1415
1416 mod migration_tests {
1422 use super::*;
1423 use tempfile::TempDir;
1424
1425 #[tokio::test]
1426 async fn test_handle_legacy_ccpm_migration_with_files_non_interactive() {
1427 let temp_dir = TempDir::new().unwrap();
1429 let project_dir = temp_dir.path();
1430
1431 std::fs::write(
1433 project_dir.join("ccpm.toml"),
1434 "[sources]\ntest = \"https://test.git\"\n",
1435 )
1436 .unwrap();
1437 std::fs::write(project_dir.join("ccpm.lock"), "version = 1\n").unwrap();
1438
1439 let result = handle_legacy_ccpm_migration(Some(project_dir.to_path_buf()), false).await;
1441 assert!(result.is_ok());
1442 assert!(result.unwrap().is_none());
1443
1444 assert!(project_dir.join("ccpm.toml").exists());
1446 assert!(!project_dir.join("agpm.toml").exists());
1447 }
1448
1449 #[tokio::test]
1450 async fn test_handle_legacy_format_migration_no_migration_needed() {
1451 let temp_dir = TempDir::new().unwrap();
1452 let project_dir = temp_dir.path();
1453
1454 let agents_dir = project_dir.join(".claude/agents/agpm");
1456 std::fs::create_dir_all(&agents_dir).unwrap();
1457 std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1458
1459 let lockfile = r#"version = 1
1461
1462[[agents]]
1463name = "test"
1464source = "test"
1465path = "agents/test.md"
1466version = "v1.0.0"
1467resolved_commit = "abc123"
1468checksum = "sha256:abc"
1469context_checksum = "sha256:def"
1470installed_at = ".claude/agents/agpm/test.md"
1471dependencies = []
1472resource_type = "Agent"
1473tool = "claude-code"
1474"#;
1475 std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1476
1477 let result = handle_legacy_format_migration(project_dir, false).await;
1479 assert!(result.is_ok());
1480 assert!(!result.unwrap()); }
1482
1483 #[tokio::test]
1484 async fn test_handle_legacy_format_migration_with_old_paths_non_interactive() {
1485 let temp_dir = TempDir::new().unwrap();
1486 let project_dir = temp_dir.path();
1487
1488 let agents_dir = project_dir.join(".claude/agents");
1490 std::fs::create_dir_all(&agents_dir).unwrap();
1491 std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1492
1493 let lockfile = r#"version = 1
1495
1496[[agents]]
1497name = "test"
1498source = "test"
1499path = "agents/test.md"
1500version = "v1.0.0"
1501resolved_commit = "abc123"
1502checksum = "sha256:abc"
1503context_checksum = "sha256:def"
1504installed_at = ".claude/agents/test.md"
1505dependencies = []
1506resource_type = "Agent"
1507tool = "claude-code"
1508"#;
1509 std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1510
1511 let result = handle_legacy_format_migration(project_dir, false).await;
1513 assert!(result.is_ok());
1514 assert!(!result.unwrap()); assert!(agents_dir.join("test.md").exists());
1518 assert!(!agents_dir.join("agpm/test.md").exists());
1519 }
1520
1521 #[tokio::test]
1522 async fn test_handle_legacy_format_migration_with_gitignore_section_non_interactive() {
1523 let temp_dir = TempDir::new().unwrap();
1524 let project_dir = temp_dir.path();
1525
1526 let gitignore = r#"# User entries
1528node_modules/
1529
1530# AGPM managed entries - do not edit below this line
1531.claude/agents/test.md
1532# End of AGPM managed entries
1533"#;
1534 std::fs::write(project_dir.join(".gitignore"), gitignore).unwrap();
1535
1536 std::fs::write(project_dir.join("agpm.lock"), "version = 1\n").unwrap();
1538
1539 let result = handle_legacy_format_migration(project_dir, false).await;
1541 assert!(result.is_ok());
1542 assert!(!result.unwrap()); let content = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap();
1546 assert!(content.contains("# AGPM managed entries"));
1547 }
1548
1549 #[tokio::test]
1550 async fn test_handle_legacy_format_migration_no_lockfile() {
1551 let temp_dir = TempDir::new().unwrap();
1552 let project_dir = temp_dir.path();
1553
1554 let agents_dir = project_dir.join(".claude/agents");
1556 std::fs::create_dir_all(&agents_dir).unwrap();
1557 std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1558
1559 let result = handle_legacy_format_migration(project_dir, false).await;
1562 assert!(result.is_ok());
1563 assert!(!result.unwrap()); }
1565 }
1566}