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
967pub async fn handle_missing_gitignore_entries(
1011 validation: &crate::installer::ConfigValidation,
1012 project_dir: &Path,
1013 yes: bool,
1014) -> Result<bool> {
1015 use super::migrate::{AGPM_MANAGED_PATHS, AGPM_MANAGED_PATHS_END};
1016 use tokio::io::AsyncWriteExt;
1017
1018 if validation.missing_gitignore_entries.is_empty() {
1020 return Ok(false);
1021 }
1022
1023 let missing = &validation.missing_gitignore_entries;
1024 let gitignore_path = project_dir.join(".gitignore");
1025
1026 if gitignore_path.exists() {
1028 if let Ok(content) = tokio::fs::read_to_string(&gitignore_path).await {
1029 if content.contains(AGPM_MANAGED_PATHS) {
1030 eprintln!("\n{}", "Warning: Missing gitignore entries detected:".yellow());
1033 for entry in missing {
1034 eprintln!(" {}", entry);
1035 }
1036 eprintln!(
1037 "\nThe {} section exists but may need manual updates.",
1038 AGPM_MANAGED_PATHS.cyan()
1039 );
1040 return Ok(false);
1041 }
1042 }
1043 }
1044
1045 if !yes && !std::io::stdin().is_terminal() {
1047 eprintln!("\n{}", "Missing gitignore entries detected:".yellow());
1049 for entry in missing {
1050 eprintln!(" {}", entry);
1051 }
1052 eprintln!("\nRun with {} to add them automatically, or add manually.", "--yes".cyan());
1053 return Ok(false);
1054 }
1055
1056 println!("\n{}", "Missing .gitignore entries detected:".yellow().bold());
1058 for entry in missing {
1059 println!(" {} {}", "→".cyan(), entry);
1060 }
1061 println!();
1062
1063 let should_add = if yes {
1065 true
1066 } else {
1067 print!("{} ", "Would you like to add them now? [Y/n]:".green());
1068 io::stdout().flush()?;
1069
1070 let mut reader = BufReader::new(tokio::io::stdin());
1071 let mut response = String::new();
1072 reader.read_line(&mut response).await?;
1073 let response = response.trim().to_lowercase();
1074 response.is_empty() || response == "y" || response == "yes"
1075 };
1076
1077 if !should_add {
1078 println!("{}", "Skipped adding gitignore entries.".yellow());
1079 return Ok(false);
1080 }
1081
1082 let mut content = String::new();
1084
1085 if gitignore_path.exists() {
1087 let existing = tokio::fs::read_to_string(&gitignore_path).await.unwrap_or_default();
1088 if !existing.is_empty() && !existing.ends_with('\n') {
1089 content.push('\n');
1090 }
1091 content.push('\n');
1092 }
1093
1094 content.push_str(AGPM_MANAGED_PATHS);
1095 content.push('\n');
1096 content.push_str(".claude/*/agpm/\n");
1097 content.push_str(".opencode/*/agpm/\n");
1098 content.push_str(".agpm/\n");
1099 content.push_str("agpm.private.toml\n");
1100 content.push_str("agpm.private.lock\n");
1101 content.push_str(AGPM_MANAGED_PATHS_END);
1102 content.push('\n');
1103
1104 let mut file = tokio::fs::OpenOptions::new()
1106 .create(true)
1107 .append(true)
1108 .open(&gitignore_path)
1109 .await
1110 .context("Failed to open .gitignore for writing")?;
1111
1112 file.write_all(content.as_bytes()).await.context("Failed to write to .gitignore")?;
1113
1114 file.sync_all().await.context("Failed to sync .gitignore")?;
1116
1117 println!("{} Added AGPM managed paths section to .gitignore", "✓".green());
1118
1119 Ok(true)
1120}
1121
1122#[cfg(test)]
1123mod tests {
1124 use super::*;
1125 use tempfile::TempDir;
1126
1127 #[test]
1128 fn test_command_context_from_manifest_path() {
1129 let temp_dir = TempDir::new().unwrap();
1130 let manifest_path = temp_dir.path().join("agpm.toml");
1131
1132 std::fs::write(
1134 &manifest_path,
1135 r#"
1136[sources]
1137test = "https://github.com/test/repo.git"
1138
1139[agents]
1140"#,
1141 )
1142 .unwrap();
1143
1144 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1145
1146 assert_eq!(context.manifest_path, manifest_path);
1147 assert_eq!(context.project_dir, temp_dir.path());
1148 assert_eq!(context.lockfile_path, temp_dir.path().join("agpm.lock"));
1149 assert!(context.manifest.sources.contains_key("test"));
1150 }
1151
1152 #[test]
1153 fn test_command_context_missing_manifest() {
1154 let result = CommandContext::from_manifest_path("/nonexistent/agpm.toml");
1155 assert!(result.is_err());
1156 assert!(result.unwrap_err().to_string().contains("not found"));
1157 }
1158
1159 #[test]
1160 fn test_command_context_invalid_manifest() {
1161 let temp_dir = TempDir::new().unwrap();
1162 let manifest_path = temp_dir.path().join("agpm.toml");
1163
1164 std::fs::write(&manifest_path, "invalid toml {{").unwrap();
1166
1167 let result = CommandContext::from_manifest_path(&manifest_path);
1168 assert!(result.is_err());
1169 assert!(result.unwrap_err().to_string().contains("Failed to parse manifest"));
1170 }
1171
1172 #[test]
1173 fn test_load_lockfile_exists() {
1174 let temp_dir = TempDir::new().unwrap();
1175 let manifest_path = temp_dir.path().join("agpm.toml");
1176 let lockfile_path = temp_dir.path().join("agpm.lock");
1177
1178 std::fs::write(&manifest_path, "[sources]\n").unwrap();
1180 std::fs::write(
1181 &lockfile_path,
1182 r#"
1183version = 1
1184
1185[[sources]]
1186name = "test"
1187url = "https://github.com/test/repo.git"
1188commit = "abc123"
1189fetched_at = "2024-01-01T00:00:00Z"
1190"#,
1191 )
1192 .unwrap();
1193
1194 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1195 let lockfile = context.load_lockfile().unwrap();
1196
1197 assert!(lockfile.is_some());
1198 let lockfile = lockfile.unwrap();
1199 assert_eq!(lockfile.sources.len(), 1);
1200 assert_eq!(lockfile.sources[0].name, "test");
1201 }
1202
1203 #[test]
1204 fn test_load_lockfile_not_exists() {
1205 let temp_dir = TempDir::new().unwrap();
1206 let manifest_path = temp_dir.path().join("agpm.toml");
1207
1208 std::fs::write(&manifest_path, "[sources]\n").unwrap();
1209
1210 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1211 let lockfile = context.load_lockfile().unwrap();
1212
1213 assert!(lockfile.is_none());
1214 }
1215
1216 #[test]
1217 fn test_save_lockfile() {
1218 let temp_dir = TempDir::new().unwrap();
1219 let manifest_path = temp_dir.path().join("agpm.toml");
1220
1221 std::fs::write(&manifest_path, "[sources]\n").unwrap();
1222
1223 let context = CommandContext::from_manifest_path(&manifest_path).unwrap();
1224
1225 let lockfile = crate::lockfile::LockFile {
1226 version: 1,
1227 sources: vec![],
1228 agents: vec![],
1229 snippets: vec![],
1230 commands: vec![],
1231 scripts: vec![],
1232 hooks: vec![],
1233 mcp_servers: vec![],
1234 skills: vec![],
1235 manifest_hash: None,
1236 has_mutable_deps: None,
1237 resource_count: None,
1238 };
1239
1240 context.save_lockfile(&lockfile).unwrap();
1241
1242 assert!(context.lockfile_path.exists());
1243 let saved_content = std::fs::read_to_string(&context.lockfile_path).unwrap();
1244 assert!(saved_content.contains("version = 1"));
1245 }
1246
1247 #[test]
1248 fn test_check_for_legacy_ccpm_no_files() {
1249 let temp_dir = TempDir::new().unwrap();
1250 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1251 assert!(result.is_none());
1252 }
1253
1254 #[test]
1255 fn test_check_for_legacy_ccpm_toml_only() {
1256 let temp_dir = TempDir::new().unwrap();
1257 std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1258
1259 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1260 assert!(result.is_some());
1261 let msg = result.unwrap();
1262 assert!(msg.contains("Legacy CCPM files detected"));
1263 assert!(msg.contains("ccpm.toml"));
1264 assert!(msg.contains("agpm migrate"));
1265 }
1266
1267 #[test]
1268 fn test_check_for_legacy_ccpm_lock_only() {
1269 let temp_dir = TempDir::new().unwrap();
1270 std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1271
1272 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1273 assert!(result.is_some());
1274 let msg = result.unwrap();
1275 assert!(msg.contains("ccpm.lock"));
1276 }
1277
1278 #[test]
1279 fn test_check_for_legacy_ccpm_both_files() {
1280 let temp_dir = TempDir::new().unwrap();
1281 std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1282 std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1283
1284 let result = check_for_legacy_ccpm_files_from(temp_dir.path().to_path_buf());
1285 assert!(result.is_some());
1286 let msg = result.unwrap();
1287 assert!(msg.contains("ccpm.toml and ccpm.lock"));
1288 }
1289
1290 #[test]
1291 fn test_find_legacy_ccpm_directory_no_files() {
1292 let temp_dir = TempDir::new().unwrap();
1293 let result = find_legacy_ccpm_directory(temp_dir.path());
1294 assert!(result.is_none());
1295 }
1296
1297 #[test]
1298 fn test_find_legacy_ccpm_directory_in_current_dir() {
1299 let temp_dir = TempDir::new().unwrap();
1300 std::fs::write(temp_dir.path().join("ccpm.toml"), "[sources]\n").unwrap();
1301
1302 let result = find_legacy_ccpm_directory(temp_dir.path());
1303 assert!(result.is_some());
1304 assert_eq!(result.unwrap(), temp_dir.path());
1305 }
1306
1307 #[test]
1308 fn test_find_legacy_ccpm_directory_in_parent() {
1309 let temp_dir = TempDir::new().unwrap();
1310 let parent = temp_dir.path();
1311 let child = parent.join("subdir");
1312 std::fs::create_dir(&child).unwrap();
1313
1314 std::fs::write(parent.join("ccpm.toml"), "[sources]\n").unwrap();
1316
1317 let result = find_legacy_ccpm_directory(&child);
1319 assert!(result.is_some());
1320 assert_eq!(result.unwrap(), parent);
1321 }
1322
1323 #[test]
1324 fn test_find_legacy_ccpm_directory_finds_lock_file() {
1325 let temp_dir = TempDir::new().unwrap();
1326 std::fs::write(temp_dir.path().join("ccpm.lock"), "# lock\n").unwrap();
1327
1328 let result = find_legacy_ccpm_directory(temp_dir.path());
1329 assert!(result.is_some());
1330 assert_eq!(result.unwrap(), temp_dir.path());
1331 }
1332
1333 #[tokio::test]
1334 async fn test_handle_legacy_ccpm_migration_no_files() -> Result<()> {
1335 let temp_dir = TempDir::new()?;
1336
1337 let result = handle_legacy_ccpm_migration(Some(temp_dir.path().to_path_buf()), false).await;
1339
1340 assert!(result?.is_none());
1341 Ok(())
1342 }
1343
1344 #[cfg(test)]
1345 mod lockfile_regeneration_tests {
1346 use super::*;
1347 use crate::manifest::Manifest;
1348 use tempfile::TempDir;
1349
1350 #[test]
1351 fn test_load_lockfile_with_regeneration_valid_lockfile() {
1352 let temp_dir = TempDir::new().unwrap();
1353 let project_dir = temp_dir.path();
1354 let manifest_path = project_dir.join("agpm.toml");
1355 let lockfile_path = project_dir.join("agpm.lock");
1356
1357 let manifest_content = r#"[sources]
1359example = "https://github.com/example/repo.git"
1360
1361[agents]
1362test = { source = "example", path = "test.md", version = "v1.0.0" }
1363"#;
1364 std::fs::write(&manifest_path, manifest_content).unwrap();
1365
1366 let lockfile_content = r#"version = 1
1368
1369[[sources]]
1370name = "example"
1371url = "https://github.com/example/repo.git"
1372commit = "abc123def456789012345678901234567890abcd"
1373fetched_at = "2024-01-01T00:00:00Z"
1374
1375[[agents]]
1376name = "test"
1377source = "example"
1378path = "test.md"
1379version = "v1.0.0"
1380resolved_commit = "abc123def456789012345678901234567890abcd"
1381checksum = "sha256:examplechecksum"
1382installed_at = ".claude/agents/test.md"
1383"#;
1384 std::fs::write(&lockfile_path, lockfile_content).unwrap();
1385
1386 let manifest = Manifest::load(&manifest_path).unwrap();
1388 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1389
1390 let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1391 assert!(result.is_some());
1392 }
1393
1394 #[test]
1395 fn test_load_lockfile_with_regeneration_invalid_toml() {
1396 let temp_dir = TempDir::new().unwrap();
1397 let project_dir = temp_dir.path();
1398 let manifest_path = project_dir.join("agpm.toml");
1399 let lockfile_path = project_dir.join("agpm.lock");
1400
1401 let manifest_content = r#"[sources]
1403example = "https://github.com/example/repo.git"
1404"#;
1405 std::fs::write(&manifest_path, manifest_content).unwrap();
1406
1407 std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1409
1410 let manifest = Manifest::load(&manifest_path).unwrap();
1412 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1413
1414 let result = ctx.load_lockfile_with_regeneration(true, "test");
1416 assert!(result.is_err());
1417
1418 let error_msg = result.unwrap_err().to_string();
1419 assert!(error_msg.contains("Invalid or corrupted lockfile detected"));
1420 assert!(error_msg.contains("beta software"));
1421 assert!(error_msg.contains("cp agpm.lock"));
1422 }
1423
1424 #[test]
1425 fn test_load_lockfile_with_regeneration_missing_lockfile() {
1426 let temp_dir = TempDir::new().unwrap();
1427 let project_dir = temp_dir.path();
1428 let manifest_path = project_dir.join("agpm.toml");
1429
1430 let manifest_content = r#"[sources]
1432example = "https://github.com/example/repo.git"
1433"#;
1434 std::fs::write(&manifest_path, manifest_content).unwrap();
1435
1436 let manifest = Manifest::load(&manifest_path).unwrap();
1438 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1439
1440 let result = ctx.load_lockfile_with_regeneration(true, "test").unwrap();
1441 assert!(result.is_none()); }
1443
1444 #[test]
1445 fn test_load_lockfile_with_regeneration_version_incompatibility() {
1446 let temp_dir = TempDir::new().unwrap();
1447 let project_dir = temp_dir.path();
1448 let manifest_path = project_dir.join("agpm.toml");
1449 let lockfile_path = project_dir.join("agpm.lock");
1450
1451 let manifest_content = r#"[sources]
1453example = "https://github.com/example/repo.git"
1454"#;
1455 std::fs::write(&manifest_path, manifest_content).unwrap();
1456
1457 let lockfile_content = r#"version = 999
1459
1460[[sources]]
1461name = "example"
1462url = "https://github.com/example/repo.git"
1463commit = "abc123def456789012345678901234567890abcd"
1464fetched_at = "2024-01-01T00:00:00Z"
1465"#;
1466 std::fs::write(&lockfile_path, lockfile_content).unwrap();
1467
1468 let manifest = Manifest::load(&manifest_path).unwrap();
1470 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1471
1472 let result = ctx.load_lockfile_with_regeneration(true, "test");
1473 assert!(result.is_err());
1474
1475 let error_msg = result.unwrap_err().to_string();
1476 assert!(error_msg.contains("version") || error_msg.contains("newer"));
1477 }
1478
1479 #[test]
1480 fn test_load_lockfile_with_regeneration_cannot_regenerate() {
1481 let temp_dir = TempDir::new().unwrap();
1482 let project_dir = temp_dir.path();
1483 let manifest_path = project_dir.join("agpm.toml");
1484 let lockfile_path = project_dir.join("agpm.lock");
1485
1486 let manifest_content = r#"[sources]
1488example = "https://github.com/example/repo.git"
1489"#;
1490 std::fs::write(&manifest_path, manifest_content).unwrap();
1491
1492 std::fs::write(&lockfile_path, "invalid toml [[[").unwrap();
1494
1495 let manifest = Manifest::load(&manifest_path).unwrap();
1497 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1498
1499 let result = ctx.load_lockfile_with_regeneration(false, "test");
1500 assert!(result.is_err());
1501
1502 let error_msg = result.unwrap_err().to_string();
1504 assert!(!error_msg.contains("Invalid or corrupted lockfile detected"));
1505 assert!(
1506 error_msg.contains("Failed to load lockfile")
1507 || error_msg.contains("Invalid TOML syntax")
1508 );
1509 }
1510
1511 #[test]
1512 fn test_backup_and_regenerate_lockfile() {
1513 let temp_dir = TempDir::new().unwrap();
1514 let project_dir = temp_dir.path();
1515 let manifest_path = project_dir.join("agpm.toml");
1516 let lockfile_path = project_dir.join("agpm.lock");
1517
1518 let manifest_content = r#"[sources]
1520example = "https://github.com/example/repo.git"
1521"#;
1522 std::fs::write(&manifest_path, manifest_content).unwrap();
1523
1524 std::fs::write(&lockfile_path, "invalid content").unwrap();
1526
1527 let manifest = Manifest::load(&manifest_path).unwrap();
1529 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1530
1531 let backup_path = lockfile_path.with_extension("lock.invalid");
1532
1533 ctx.backup_and_regenerate_lockfile(&backup_path, "test").unwrap();
1535
1536 assert!(backup_path.exists());
1538 assert_eq!(std::fs::read_to_string(&backup_path).unwrap(), "invalid content");
1539
1540 assert!(!lockfile_path.exists());
1542 }
1543
1544 #[test]
1545 fn test_create_non_interactive_error() {
1546 let temp_dir = TempDir::new().unwrap();
1547 let project_dir = temp_dir.path();
1548 let manifest_path = project_dir.join("agpm.toml");
1549
1550 let manifest_content = r#"[sources]
1552example = "https://github.com/example/repo.git"
1553"#;
1554 std::fs::write(&manifest_path, manifest_content).unwrap();
1555
1556 let manifest = Manifest::load(&manifest_path).unwrap();
1558 let ctx = CommandContext::new(manifest, project_dir.to_path_buf()).unwrap();
1559
1560 let error = ctx.create_non_interactive_error("Invalid TOML syntax", "test");
1561 let error_msg = error.to_string();
1562
1563 assert!(error_msg.contains("Invalid TOML syntax"));
1564 assert!(error_msg.contains("beta software"));
1565 assert!(error_msg.contains("cp agpm.lock"));
1566 assert!(error_msg.contains("rm agpm.lock"));
1567 assert!(error_msg.contains("agpm install"));
1568 }
1569 }
1570
1571 mod migration_tests {
1577 use super::*;
1578 use tempfile::TempDir;
1579
1580 #[tokio::test]
1581 async fn test_handle_legacy_ccpm_migration_with_files_non_interactive() {
1582 let temp_dir = TempDir::new().unwrap();
1584 let project_dir = temp_dir.path();
1585
1586 std::fs::write(
1588 project_dir.join("ccpm.toml"),
1589 "[sources]\ntest = \"https://test.git\"\n",
1590 )
1591 .unwrap();
1592 std::fs::write(project_dir.join("ccpm.lock"), "version = 1\n").unwrap();
1593
1594 let result = handle_legacy_ccpm_migration(Some(project_dir.to_path_buf()), false).await;
1596 assert!(result.is_ok());
1597 assert!(result.unwrap().is_none());
1598
1599 assert!(project_dir.join("ccpm.toml").exists());
1601 assert!(!project_dir.join("agpm.toml").exists());
1602 }
1603
1604 #[tokio::test]
1605 async fn test_handle_legacy_format_migration_no_migration_needed() {
1606 let temp_dir = TempDir::new().unwrap();
1607 let project_dir = temp_dir.path();
1608
1609 let agents_dir = project_dir.join(".claude/agents/agpm");
1611 std::fs::create_dir_all(&agents_dir).unwrap();
1612 std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1613
1614 let lockfile = r#"version = 1
1616
1617[[agents]]
1618name = "test"
1619source = "test"
1620path = "agents/test.md"
1621version = "v1.0.0"
1622resolved_commit = "abc123"
1623checksum = "sha256:abc"
1624context_checksum = "sha256:def"
1625installed_at = ".claude/agents/agpm/test.md"
1626dependencies = []
1627resource_type = "Agent"
1628tool = "claude-code"
1629"#;
1630 std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1631
1632 let result = handle_legacy_format_migration(project_dir, false).await;
1634 assert!(result.is_ok());
1635 assert!(!result.unwrap()); }
1637
1638 #[tokio::test]
1639 async fn test_handle_legacy_format_migration_with_old_paths_non_interactive() {
1640 let temp_dir = TempDir::new().unwrap();
1641 let project_dir = temp_dir.path();
1642
1643 let agents_dir = project_dir.join(".claude/agents");
1645 std::fs::create_dir_all(&agents_dir).unwrap();
1646 std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1647
1648 let lockfile = r#"version = 1
1650
1651[[agents]]
1652name = "test"
1653source = "test"
1654path = "agents/test.md"
1655version = "v1.0.0"
1656resolved_commit = "abc123"
1657checksum = "sha256:abc"
1658context_checksum = "sha256:def"
1659installed_at = ".claude/agents/test.md"
1660dependencies = []
1661resource_type = "Agent"
1662tool = "claude-code"
1663"#;
1664 std::fs::write(project_dir.join("agpm.lock"), lockfile).unwrap();
1665
1666 let result = handle_legacy_format_migration(project_dir, false).await;
1668 assert!(result.is_ok());
1669 assert!(!result.unwrap()); assert!(agents_dir.join("test.md").exists());
1673 assert!(!agents_dir.join("agpm/test.md").exists());
1674 }
1675
1676 #[tokio::test]
1677 async fn test_handle_legacy_format_migration_with_gitignore_section_non_interactive() {
1678 let temp_dir = TempDir::new().unwrap();
1679 let project_dir = temp_dir.path();
1680
1681 let gitignore = r#"# User entries
1683node_modules/
1684
1685# AGPM managed entries - do not edit below this line
1686.claude/agents/test.md
1687# End of AGPM managed entries
1688"#;
1689 std::fs::write(project_dir.join(".gitignore"), gitignore).unwrap();
1690
1691 std::fs::write(project_dir.join("agpm.lock"), "version = 1\n").unwrap();
1693
1694 let result = handle_legacy_format_migration(project_dir, false).await;
1696 assert!(result.is_ok());
1697 assert!(!result.unwrap()); let content = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap();
1701 assert!(content.contains("# AGPM managed entries"));
1702 }
1703
1704 #[tokio::test]
1705 async fn test_handle_legacy_format_migration_no_lockfile() {
1706 let temp_dir = TempDir::new().unwrap();
1707 let project_dir = temp_dir.path();
1708
1709 let agents_dir = project_dir.join(".claude/agents");
1711 std::fs::create_dir_all(&agents_dir).unwrap();
1712 std::fs::write(agents_dir.join("test.md"), "# Test Agent").unwrap();
1713
1714 let result = handle_legacy_format_migration(project_dir, false).await;
1717 assert!(result.is_ok());
1718 assert!(!result.unwrap()); }
1720 }
1721
1722 mod gitignore_offering_tests {
1724 use super::*;
1725 use crate::installer::ConfigValidation;
1726 use tempfile::TempDir;
1727
1728 #[tokio::test]
1729 async fn test_handle_missing_gitignore_no_entries() {
1730 let temp_dir = TempDir::new().unwrap();
1731 let validation = ConfigValidation::default(); let result =
1734 handle_missing_gitignore_entries(&validation, temp_dir.path(), false).await;
1735
1736 assert!(result.is_ok());
1737 assert!(!result.unwrap()); }
1739
1740 #[tokio::test]
1741 async fn test_handle_missing_gitignore_with_yes_flag() {
1742 let temp_dir = TempDir::new().unwrap();
1743 let validation = ConfigValidation {
1744 missing_gitignore_entries: vec![
1745 ".claude/agents/agpm/".to_string(),
1746 "agpm.private.toml".to_string(),
1747 ],
1748 ..Default::default()
1749 };
1750
1751 let result = handle_missing_gitignore_entries(
1753 &validation,
1754 temp_dir.path(),
1755 true, )
1757 .await;
1758
1759 assert!(result.is_ok());
1760 assert!(result.unwrap()); let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1764 assert!(content.contains("# AGPM managed paths"));
1765 assert!(content.contains(".claude/*/agpm/"));
1766 assert!(content.contains(".opencode/*/agpm/"));
1767 assert!(content.contains(".agpm/"));
1768 assert!(content.contains("agpm.private.toml"));
1769 assert!(content.contains("agpm.private.lock"));
1770 assert!(content.contains("# End of AGPM managed paths"));
1771 }
1772
1773 #[tokio::test]
1774 async fn test_handle_missing_gitignore_appends_to_existing() {
1775 let temp_dir = TempDir::new().unwrap();
1776
1777 std::fs::write(temp_dir.path().join(".gitignore"), "node_modules/\n.env\n").unwrap();
1779
1780 let validation = ConfigValidation {
1781 missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1782 ..Default::default()
1783 };
1784
1785 let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1786
1787 assert!(result.is_ok());
1788 assert!(result.unwrap());
1789
1790 let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1791 assert!(content.contains("node_modules/"));
1793 assert!(content.contains(".env"));
1794 assert!(content.contains("# AGPM managed paths"));
1796 assert!(content.contains(".claude/*/agpm/"));
1797 }
1798
1799 #[tokio::test]
1800 async fn test_handle_missing_gitignore_non_interactive_no_yes() {
1801 let temp_dir = TempDir::new().unwrap();
1803 let validation = ConfigValidation {
1804 missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1805 ..Default::default()
1806 };
1807
1808 let result = handle_missing_gitignore_entries(
1809 &validation,
1810 temp_dir.path(),
1811 false, )
1813 .await;
1814
1815 assert!(result.is_ok());
1816 assert!(!result.unwrap()); assert!(!temp_dir.path().join(".gitignore").exists());
1820 }
1821
1822 #[tokio::test]
1823 async fn test_handle_missing_gitignore_skips_if_section_exists() {
1824 let temp_dir = TempDir::new().unwrap();
1825
1826 let existing = r#"node_modules/
1828
1829# AGPM managed paths
1830.claude/*/agpm/
1831# End of AGPM managed paths
1832"#;
1833 std::fs::write(temp_dir.path().join(".gitignore"), existing).unwrap();
1834
1835 let validation = ConfigValidation {
1837 missing_gitignore_entries: vec!["agpm.private.toml".to_string()],
1838 ..Default::default()
1839 };
1840
1841 let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1842
1843 assert!(result.is_ok());
1844 assert!(!result.unwrap()); let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1848 assert_eq!(content, existing);
1849 }
1850
1851 #[tokio::test]
1852 async fn test_handle_missing_gitignore_creates_new_file() {
1853 let temp_dir = TempDir::new().unwrap();
1854
1855 assert!(!temp_dir.path().join(".gitignore").exists());
1857
1858 let validation = ConfigValidation {
1859 missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1860 ..Default::default()
1861 };
1862
1863 let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1864
1865 assert!(result.is_ok());
1866 assert!(result.unwrap());
1867
1868 assert!(temp_dir.path().join(".gitignore").exists());
1870 let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1871 assert!(content.contains("# AGPM managed paths"));
1872 }
1873
1874 #[tokio::test]
1875 async fn test_handle_missing_gitignore_handles_file_without_newline() {
1876 let temp_dir = TempDir::new().unwrap();
1877
1878 std::fs::write(temp_dir.path().join(".gitignore"), "node_modules/").unwrap();
1880
1881 let validation = ConfigValidation {
1882 missing_gitignore_entries: vec![".claude/agents/agpm/".to_string()],
1883 ..Default::default()
1884 };
1885
1886 let result = handle_missing_gitignore_entries(&validation, temp_dir.path(), true).await;
1887
1888 assert!(result.is_ok());
1889 assert!(result.unwrap());
1890
1891 let content = std::fs::read_to_string(temp_dir.path().join(".gitignore")).unwrap();
1892 assert!(content.contains("node_modules/\n\n# AGPM managed paths"));
1894 }
1895 }
1896}