diff --git a/internal/adapters/secondary/repository/repository_test.go b/internal/adapters/secondary/repository/repository_test.go index 813a305b..6098cec2 100644 --- a/internal/adapters/secondary/repository/repository_test.go +++ b/internal/adapters/secondary/repository/repository_test.go @@ -6,6 +6,7 @@ import ( "errors" "io" "os" + "path/filepath" "testing" "time" @@ -27,6 +28,7 @@ func NewMockFileSystem() *MockFileSystem { } func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { + name = filepath.ToSlash(name) if data, ok := m.files[name]; ok { return data, nil } @@ -34,6 +36,7 @@ func (m *MockFileSystem) ReadFile(name string) ([]byte, error) { } func (m *MockFileSystem) WriteFile(name string, data []byte, _ os.FileMode) error { + name = filepath.ToSlash(name) m.files[name] = data return nil } @@ -43,6 +46,7 @@ func (m *MockFileSystem) MkdirAll(_ string, _ os.FileMode) error { } func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { + name = filepath.ToSlash(name) if _, ok := m.files[name]; ok { //nolint:nilnil // Mock for testing return nil, nil @@ -51,6 +55,7 @@ func (m *MockFileSystem) Stat(name string) (os.FileInfo, error) { } func (m *MockFileSystem) Remove(name string) error { + name = filepath.ToSlash(name) delete(m.files, name) return nil } @@ -60,6 +65,8 @@ func (m *MockFileSystem) RemoveAll(_ string) error { } func (m *MockFileSystem) Rename(oldpath, newpath string) error { + oldpath = filepath.ToSlash(oldpath) + newpath = filepath.ToSlash(newpath) if data, ok := m.files[oldpath]; ok { m.files[newpath] = data delete(m.files, oldpath) @@ -82,6 +89,7 @@ func (m *MockFileSystem) Create(_ string) (io.WriteCloser, error) { } func (m *MockFileSystem) Exists(name string) bool { + name = filepath.ToSlash(name) _, ok := m.files[name] return ok } diff --git a/internal/core/services/compiler/executor.go b/internal/core/services/compiler/executor.go index b9b5c3f9..e86f027e 100644 --- a/internal/core/services/compiler/executor.go +++ b/internal/core/services/compiler/executor.go @@ -55,8 +55,60 @@ func buildSearchPath(dep *domain.Dependency) string { return searchPath } +func compileLazarus(lazarusPath string, tracker *BuildTracker) bool { + if tracker == nil || !tracker.IsEnabled() { + msg.Info(" 🔨 Building Lazarus project/package: " + filepath.Base(lazarusPath)) + } + + _, err := exec.LookPath("lazbuild") + if err != nil { + if tracker == nil || !tracker.IsEnabled() { + msg.Err(" ❌ 'lazbuild' compiler not found on PATH. Please install Lazarus/lazbuild to compile.") + } + return false + } + + absPath, _ := filepath.Abs(lazarusPath) + absDir := filepath.Dir(absPath) + + cmd := exec.Command("lazbuild", "--build-mode=Debug", absPath) + cmd.Dir = absDir + + buildLog := filepath.Join(absDir, "build_boss_" + strings.TrimSuffix(filepath.Base(lazarusPath), filepath.Ext(lazarusPath)) + ".log") + logFile, err := os.OpenFile(buildLog, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) + if err != nil { + if tracker == nil || !tracker.IsEnabled() { + msg.Warn(" ⚠️ Error creating build log file: %v", err) + } + return false + } + defer logFile.Close() + + cmd.Stdout = logFile + cmd.Stderr = logFile + + if err := cmd.Run(); err != nil { + if tracker == nil || !tracker.IsEnabled() { + msg.Err(" ❌ Failed to compile, see " + buildLog + " for more information: %v", err) + } + return false + } + + if tracker == nil || !tracker.IsEnabled() { + msg.Info(" ✅️ Success!") + } + + _ = os.Remove(buildLog) + return true +} + //nolint:funlen,gocognit,lll // Complex compilation orchestration with long function signature func compile(dprojPath string, dep *domain.Dependency, rootLock domain.PackageLock, tracker *BuildTracker, selectedCompiler *compilerselector.SelectedCompiler) bool { + ext := strings.ToLower(filepath.Ext(dprojPath)) + if ext == ".lpi" || ext == ".lpk" { + return compileLazarus(dprojPath, tracker) + } + if tracker == nil || !tracker.IsEnabled() { msg.Info(" 🔨 Building " + filepath.Base(dprojPath)) } diff --git a/internal/core/services/installer/core.go b/internal/core/services/installer/core.go index b22c080c..113a99c1 100644 --- a/internal/core/services/installer/core.go +++ b/internal/core/services/installer/core.go @@ -118,9 +118,11 @@ func DoInstall(config env.ConfigProvider, options InstallOptions, pkg *domain.Pa msg.SetProgressTracker(nil) progress.Stop() - paths.EnsureCleanModulesDir(dependencies, pkg.Lock) + paths.EnsureCleanModulesDir(dependencies, pkg.Lock, len(options.Args) == 0) - pkg.Lock.CleanRemoved(dependencies) + if len(options.Args) == 0 { + pkg.Lock.CleanRemoved(dependencies) + } if err := pkgmanager.SavePackageCurrent(pkg); err != nil { msg.Warn("⚠️ Failed to save package: %v", err) } diff --git a/internal/core/services/installer/core_test.go b/internal/core/services/installer/core_test.go index bc71b67e..25f170d9 100644 --- a/internal/core/services/installer/core_test.go +++ b/internal/core/services/installer/core_test.go @@ -68,3 +68,27 @@ func TestAddWarning(t *testing.T) { t.Errorf("Expected warning 'Test warning', got %q", ctx.warnings[0]) } } + +func TestCollectDependenciesToInstall_WithFilter(t *testing.T) { + pkg := &domain.Package{ + Dependencies: map[string]string{ + "github.com/hashload/horse": "^1.0.0", + "github.com/hashload/boss": "^2.0.0", + }, + } + + // Test empty filter (should return all) + all := collectDependenciesToInstall(pkg, []string{}) + if len(all) != 2 { + t.Errorf("Expected 2 dependencies, got %d", len(all)) + } + + // Test with filter (should return only matching) + filtered := collectDependenciesToInstall(pkg, []string{"horse"}) + if len(filtered) != 1 { + t.Errorf("Expected 1 dependency, got %d", len(filtered)) + } + if filtered[0].Repository != "github.com/hashload/horse" { + t.Errorf("Expected horse dependency, got %q", filtered[0].Repository) + } +} diff --git a/internal/core/services/paths/paths.go b/internal/core/services/paths/paths.go index f717d8c7..9896f4bf 100644 --- a/internal/core/services/paths/paths.go +++ b/internal/core/services/paths/paths.go @@ -16,7 +16,7 @@ import ( // EnsureCleanModulesDir ensures that the modules directory is clean and contains only the required dependencies. // //nolint:gocognit // Refactoring would reduce readability -func EnsureCleanModulesDir(dependencies []domain.Dependency, lock domain.PackageLock) { +func EnsureCleanModulesDir(dependencies []domain.Dependency, lock domain.PackageLock, cleanAll bool) { cacheDir := env.GetModulesDir() cacheDirInfo, err := os.Stat(cacheDir) if os.IsNotExist(err) { @@ -47,7 +47,7 @@ func EnsureCleanModulesDir(dependencies []domain.Dependency, lock domain.Package continue } - if !utils.Contains(dependenciesNames, info.Name()) { + if cleanAll && !utils.Contains(dependenciesNames, info.Name()) { remove: if err = os.RemoveAll(filepath.Join(cacheDir, info.Name())); err != nil { msg.Warn("⚠️ Failed to remove old cache: %s", err.Error()) diff --git a/internal/core/services/paths/paths_test.go b/internal/core/services/paths/paths_test.go index 514d6939..688bd27a 100644 --- a/internal/core/services/paths/paths_test.go +++ b/internal/core/services/paths/paths_test.go @@ -59,7 +59,7 @@ func TestEnsureCleanModulesDir_CreatesDir(t *testing.T) { } // EnsureCleanModulesDir should create the modules directory - paths.EnsureCleanModulesDir(deps, lock) + paths.EnsureCleanModulesDir(deps, lock, true) // Verify modules directory was created modulesDir := filepath.Join(tempDir, consts.FolderDependencies) @@ -114,7 +114,7 @@ func TestEnsureCleanModulesDir_RemovesOldDependencies(t *testing.T) { } // EnsureCleanModulesDir should remove old dependency - paths.EnsureCleanModulesDir(deps, lock) + paths.EnsureCleanModulesDir(deps, lock, true) // Verify old dependency was removed if _, err := os.Stat(oldDepDir); !os.IsNotExist(err) { @@ -126,3 +126,54 @@ func TestEnsureCleanModulesDir_RemovesOldDependencies(t *testing.T) { t.Error("EnsureCleanModulesDir() should keep current dependency directories") } } + +func TestEnsureCleanModulesDir_KeepOldDependenciesOnSelective(t *testing.T) { + // Create a temp directory for workspace + tempDir := t.TempDir() + + // Save original state and set not global + originalGlobal := env.GetGlobal() + defer env.SetGlobal(originalGlobal) + env.SetGlobal(false) + + // Change to temp directory + t.Chdir(tempDir) + + // Create modules directory + modulesDir := filepath.Join(tempDir, consts.FolderDependencies) + if err := os.MkdirAll(modulesDir, 0755); err != nil { + t.Fatalf("Failed to create modules dir: %v", err) + } + + // Create an old dependency directory that should NOT be removed because cleanAll is false + oldDepDir := filepath.Join(modulesDir, "old-dependency") + if err := os.MkdirAll(oldDepDir, 0755); err != nil { + t.Fatalf("Failed to create old dependency dir: %v", err) + } + + // Create a current dependency directory that should be kept + currentDepDir := filepath.Join(modulesDir, "horse") + if err := os.MkdirAll(currentDepDir, 0755); err != nil { + t.Fatalf("Failed to create current dependency dir: %v", err) + } + + // Define current dependencies + dep := domain.ParseDependency("github.com/hashload/horse", "^1.0.0") + deps := []domain.Dependency{dep} + lock := domain.PackageLock{ + Installed: map[string]domain.LockedDependency{}, + } + + // EnsureCleanModulesDir with cleanAll=false should keep the old dependency + paths.EnsureCleanModulesDir(deps, lock, false) + + // Verify old dependency was NOT removed + if _, err := os.Stat(oldDepDir); os.IsNotExist(err) { + t.Error("EnsureCleanModulesDir() with cleanAll=false should NOT remove old dependency directories") + } + + // Verify current dependency was kept + if _, err := os.Stat(currentDepDir); os.IsNotExist(err) { + t.Error("EnsureCleanModulesDir() should keep current dependency directories") + } +} diff --git a/pkg/consts/consts.go b/pkg/consts/consts.go index 7555b91f..e366a367 100644 --- a/pkg/consts/consts.go +++ b/pkg/consts/consts.go @@ -13,6 +13,7 @@ const ( FileExtensionDpr = ".dpr" FileExtensionDproj = ".dproj" FileExtensionLpi = ".lpi" + FileExtensionLpk = ".lpk" FilePackageLockOld = "boss.lock" FolderDependencies = "modules" diff --git a/utils/librarypath/dproj_util.go b/utils/librarypath/dproj_util.go index c9006275..3abe108f 100644 --- a/utils/librarypath/dproj_util.go +++ b/utils/librarypath/dproj_util.go @@ -18,8 +18,8 @@ import ( var ( //nolint:lll // Regex pattern readability is important - reProjectFile = regexp.MustCompile(`.*` + regexp.QuoteMeta(consts.FileExtensionDproj) + `|.*` + regexp.QuoteMeta(consts.FileExtensionLpi) + `$`) - reLazarusFile = regexp.MustCompile(`.*` + regexp.QuoteMeta(consts.FileExtensionLpi) + `$`) + reProjectFile = regexp.MustCompile(`.*` + regexp.QuoteMeta(consts.FileExtensionDproj) + `|.*` + regexp.QuoteMeta(consts.FileExtensionLpi) + `|.*` + regexp.QuoteMeta(consts.FileExtensionLpk) + `$`) + reLazarusFile = regexp.MustCompile(`.*` + regexp.QuoteMeta(consts.FileExtensionLpi) + `|.*` + regexp.QuoteMeta(consts.FileExtensionLpk) + `$`) ) // updateDprojLibraryPath updates the library path in the project file. @@ -40,28 +40,41 @@ func updateOtherUnitFilesProject(lpiName string) { doc := etree.NewDocument() info, err := os.Stat(lpiName) if os.IsNotExist(err) || info.IsDir() { - msg.Err("❌ .lpi not found.") + msg.Err("❌ Lazarus project/package file not found.") return } err = doc.ReadFromFile(lpiName) if err != nil { - msg.Err("❌ Error on read lpi: %s", err) + msg.Err("❌ Error on read lazarus file: %s", err) return } root := doc.Root() compilerOptions := root.SelectElement(consts.XMLTagNameCompilerOptions) - processCompilerOptions(compilerOptions) + if compilerOptions != nil { + processCompilerOptions(compilerOptions) + } projectOptions := root.SelectElement(consts.XMLTagNameProjectOptions) + if projectOptions != nil { + buildModes := projectOptions.SelectElement(consts.XMLTagNameBuildModes) + if buildModes != nil { + for _, item := range buildModes.SelectElements(consts.XMLTagNameItem) { + attribute := item.SelectAttr(consts.XMLNameAttribute) + compilerOptions = item.SelectElement(consts.XMLTagNameCompilerOptions) + if compilerOptions != nil { + msg.Info(" 🔁 Updating %s mode", attribute.Value) + processCompilerOptions(compilerOptions) + } + } + } + } - buildModes := projectOptions.SelectElement(consts.XMLTagNameBuildModes) - for _, item := range buildModes.SelectElements(consts.XMLTagNameItem) { - attribute := item.SelectAttr(consts.XMLNameAttribute) - compilerOptions = item.SelectElement(consts.XMLTagNameCompilerOptions) + packageOptions := root.SelectElement("Package") + if packageOptions != nil { + compilerOptions = packageOptions.SelectElement(consts.XMLTagNameCompilerOptions) if compilerOptions != nil { - msg.Info(" 🔁 Updating %s mode", attribute.Value) processCompilerOptions(compilerOptions) } } diff --git a/utils/librarypath/lazarus_test.go b/utils/librarypath/lazarus_test.go new file mode 100644 index 00000000..3d5f5d91 --- /dev/null +++ b/utils/librarypath/lazarus_test.go @@ -0,0 +1,173 @@ +//nolint:testpackage // Testing internal Lazarus utility functions +package librarypath + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/beevik/etree" + "github.com/hashload/boss/internal/adapters/secondary/filesystem" + "github.com/hashload/boss/internal/adapters/secondary/repository" + "github.com/hashload/boss/internal/core/services/packages" + "github.com/hashload/boss/pkg/pkgmanager" +) + +const mockLpiContent = ` + + + + + + </General> + <BuildModes> + <Item Name="Default" Default="True"/> + <Item Name="Debug"> + <CompilerOptions> + <SearchPaths> + <OtherUnitFiles Value="src"/> + </SearchPaths> + </CompilerOptions> + </Item> + </BuildModes> + </ProjectOptions> + <CompilerOptions> + <SearchPaths> + <OtherUnitFiles Value="src"/> + </SearchPaths> + </CompilerOptions> +</CONFIG> +` + +const mockLpkContent = `<?xml version="1.0" encoding="UTF-8"?> +<CONFIG> + <Package Name="MyPackage"> + <CompilerOptions> + <SearchPaths> + <OtherUnitFiles Value="src"/> + </SearchPaths> + </CompilerOptions> + </Package> +</CONFIG> +` + +// TestLazarusPathInjection verifies that other unit search paths are correctly injected in both .lpi and .lpk files. +func TestLazarusPathInjection(t *testing.T) { + tempDir := t.TempDir() + + // Redirect working directory + oldWd, err := os.Getwd() + if err == nil { + defer func() { _ = os.Chdir(oldWd) }() + } + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("Failed to change directory: %v", err) + } + + // Initialize package manager + fs := filesystem.NewOSFileSystem() + packageRepo := repository.NewFilePackageRepository(fs) + lockRepo := repository.NewFileLockRepository(fs) + packageService := packages.NewPackageService(packageRepo, lockRepo) + pkgmanager.SetInstance(packageService) + + // Set up a mock dependency + modulesDir := filepath.Join(tempDir, "modules") + err = os.MkdirAll(filepath.Join(modulesDir, "horse", "src"), 0755) + if err != nil { + t.Fatalf("Failed to create modules: %v", err) + } + + // Create boss.json for dependency + bossJsonContent := `{"name": "horse", "mainsrc": "src"}` + err = os.WriteFile(filepath.Join(modulesDir, "horse", "boss.json"), []byte(bossJsonContent), 0644) + if err != nil { + t.Fatalf("Failed to write dependency boss.json: %v", err) + } + + // Create a dummy source file in the dependency so it matches RegexArtifacts + err = os.WriteFile(filepath.Join(modulesDir, "horse", "src", "dummy.pas"), []byte("unit dummy;"), 0644) + if err != nil { + t.Fatalf("Failed to write dummy.pas: %v", err) + } + + // 1. Test LPI (Project) Path Injection + lpiPath := filepath.Join(tempDir, "project.lpi") + err = os.WriteFile(lpiPath, []byte(mockLpiContent), 0644) + if err != nil { + t.Fatalf("Failed to write mock LPI: %v", err) + } + + updateOtherUnitFilesProject(lpiPath) + + // Verify LPI XML contents + doc := etree.NewDocument() + if err := doc.ReadFromFile(lpiPath); err != nil { + t.Fatalf("Failed to read updated LPI: %v", err) + } + + root := doc.Root() + compOpts := root.SelectElement("CompilerOptions") + if compOpts == nil { + t.Fatal("CompilerOptions element not found in LPI") + } + searchPaths := compOpts.SelectElement("SearchPaths") + if searchPaths == nil { + t.Fatal("SearchPaths element not found in LPI") + } + otherUnitFiles := searchPaths.SelectElement("OtherUnitFiles") + if otherUnitFiles == nil { + t.Fatal("OtherUnitFiles element not found in LPI") + } + valAttr := otherUnitFiles.SelectAttr("Value") + if valAttr == nil { + t.Fatal("Value attribute not found in LPI OtherUnitFiles") + } + + expectedPath := filepath.Clean("modules/horse/src") + if !strings.Contains(filepath.Clean(valAttr.Value), expectedPath) { + t.Errorf("Expected path %q to be injected in LPI, got %q", expectedPath, valAttr.Value) + } + + // 2. Test LPK (Package) Path Injection + lpkPath := filepath.Join(tempDir, "package.lpk") + err = os.WriteFile(lpkPath, []byte(mockLpkContent), 0644) + if err != nil { + t.Fatalf("Failed to write mock LPK: %v", err) + } + + updateOtherUnitFilesProject(lpkPath) + + // Verify LPK XML contents + docLpk := etree.NewDocument() + if err := docLpk.ReadFromFile(lpkPath); err != nil { + t.Fatalf("Failed to read updated LPK: %v", err) + } + + rootLpk := docLpk.Root() + pkgOpts := rootLpk.SelectElement("Package") + if pkgOpts == nil { + t.Fatal("Package element not found in LPK") + } + compOptsLpk := pkgOpts.SelectElement("CompilerOptions") + if compOptsLpk == nil { + t.Fatal("CompilerOptions element not found in LPK") + } + searchPathsLpk := compOptsLpk.SelectElement("SearchPaths") + if searchPathsLpk == nil { + t.Fatal("SearchPaths element not found in LPK") + } + otherUnitFilesLpk := searchPathsLpk.SelectElement("OtherUnitFiles") + if otherUnitFilesLpk == nil { + t.Fatal("OtherUnitFiles element not found in LPK") + } + valAttrLpk := otherUnitFilesLpk.SelectAttr("Value") + if valAttrLpk == nil { + t.Fatal("Value attribute not found in LPK OtherUnitFiles") + } + + if !strings.Contains(filepath.Clean(valAttrLpk.Value), expectedPath) { + t.Errorf("Expected path %q to be injected in LPK, got %q", expectedPath, valAttrLpk.Value) + } +}