diff --git a/anvil.yml b/anvil.yml index 61ddd8d..331d021 100644 --- a/anvil.yml +++ b/anvil.yml @@ -21,6 +21,23 @@ templateGroups: echo "Building {{ .AppName }}..." permissions: "0755" # Make script executable + # Example: protected files that won't be overwritten + configFiles: + - destination: "config/.env.example" + content: | + # Copy this file to .env and fill in your values + API_KEY=changeme + DATABASE_URL=changeme + permissions: "0644" + + - destination: "config/.env" + content: | + # Fill in your secrets below + API_KEY=changeme + DATABASE_URL=changeme + permissions: "0600" + overwrite: false # Protect user customizations from being overwritten + projects: - name: "example/repo1" groups: diff --git a/cmd/structuresmith/app.go b/cmd/structuresmith/app.go index 231fb8d..1f77166 100644 --- a/cmd/structuresmith/app.go +++ b/cmd/structuresmith/app.go @@ -65,6 +65,7 @@ func (app *Structuresmith) diff(project string, cfg ConfigFile) error { } diffedFiles := lock.Diff(allFiles) + diffedFiles = app.applySkipLogic(diffedFiles) fmt.Printf("\n%s\n", diffedFiles) return nil } @@ -86,9 +87,21 @@ func (app *Structuresmith) render(project string, cfg ConfigFile) error { } diffedFiles := lock.Diff(allFiles) + diffedFiles = app.applySkipLogic(diffedFiles) fmt.Printf("\n%s\n", diffedFiles) + // Build a set of skipped file destinations for quick lookup + skippedSet := make(map[string]struct{}) + for _, file := range diffedFiles.SkippedFiles { + skippedSet[file.Destination] = struct{}{} + } + for _, file := range allFiles { + // Skip files that are marked as skipped (exist and have overwrite: false) + if _, shouldSkip := skippedSet[file.Destination]; shouldSkip { + log.Printf("Skipping %s (file exists and overwrite is disabled)", filepath.Join(app.OutputDir, file.Destination)) + continue + } if err = app.renderFileStructure(file); err != nil { return err } @@ -107,6 +120,52 @@ func (app *Structuresmith) render(project string, cfg ConfigFile) error { return nil } +// applySkipLogic checks which files should be skipped based on overwrite setting +// and actual file existence on disk. It moves files from NewFiles and KeptFiles +// to SkippedFiles if they exist and have overwrite: false. +func (app *Structuresmith) applySkipLogic(diff DiffResult) DiffResult { + result := DiffResult{ + DeletedFiles: diff.DeletedFiles, + SkippedFiles: diff.SkippedFiles, + } + + // Process new files - skip if file exists on disk and overwrite is false + for _, file := range diff.NewFiles { + if !shouldOverwrite(file) && app.fileExistsOnDisk(file.Destination) { + result.SkippedFiles = append(result.SkippedFiles, file) + } else { + result.NewFiles = append(result.NewFiles, file) + } + } + + // Process kept files (would be overwritten) - skip if overwrite is false + for _, file := range diff.KeptFiles { + if !shouldOverwrite(file) && app.fileExistsOnDisk(file.Destination) { + result.SkippedFiles = append(result.SkippedFiles, file) + } else { + result.KeptFiles = append(result.KeptFiles, file) + } + } + + return result +} + +// shouldOverwrite returns true if the file should be overwritten. +// Defaults to true if Overwrite is not specified (nil). +func shouldOverwrite(file FileStructure) bool { + if file.Overwrite == nil { + return true // Default behavior: overwrite + } + return *file.Overwrite +} + +// fileExistsOnDisk checks if a file exists in the output directory. +func (app *Structuresmith) fileExistsOnDisk(destination string) bool { + fullPath := filepath.Join(app.OutputDir, destination) + _, err := os.Stat(fullPath) + return err == nil +} + // renderFileStructure creates a file based on the FileStructure details. func (app *Structuresmith) renderFileStructure(file FileStructure) error { outputPath := filepath.Join(app.OutputDir, filepath.Dir(file.Destination)) @@ -290,6 +349,7 @@ func (app *Structuresmith) processDirectory(directory FileStructure) ([]FileStru Destination: filepath.Join(directory.Destination, relPath), Values: directory.Values, Permissions: directory.Permissions, + Overwrite: directory.Overwrite, }) } return nil diff --git a/cmd/structuresmith/app_test.go b/cmd/structuresmith/app_test.go index 0ddede0..3763c4b 100644 --- a/cmd/structuresmith/app_test.go +++ b/cmd/structuresmith/app_test.go @@ -210,3 +210,272 @@ func TestMergeValuesPreservesPermissions(t *testing.T) { t.Error("Permissions should not be affected by value merging") } } + +func TestShouldOverwrite(t *testing.T) { + tests := []struct { + name string + overwrite *bool + want bool + }{ + { + name: "Overwrite nil (default true)", + overwrite: nil, + want: true, + }, + { + name: "Overwrite explicitly true", + overwrite: func() *bool { b := true; return &b }(), + want: true, + }, + { + name: "Overwrite explicitly false", + overwrite: func() *bool { b := false; return &b }(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + file := FileStructure{ + Destination: "test.txt", + Content: "test", + Overwrite: tt.overwrite, + } + got := shouldOverwrite(file) + if got != tt.want { + t.Errorf("shouldOverwrite() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplySkipLogic(t *testing.T) { + // Create a temporary directory for the test + tmpDir, err := os.MkdirTemp("", "structuresmith-skip-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create an existing file + existingFile := filepath.Join(tmpDir, "existing.txt") + if err := os.WriteFile(existingFile, []byte("existing content"), 0o644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + app := &Structuresmith{ + OutputDir: tmpDir, + } + + overwriteFalse := false + overwriteTrue := true + + tests := []struct { + name string + diff DiffResult + wantNew int + wantKept int + wantSkipped int + wantDeleted int + description string + }{ + { + name: "New file with overwrite:false that exists on disk should be skipped", + diff: DiffResult{ + NewFiles: []FileStructure{ + {Destination: "existing.txt", Content: "new content", Overwrite: &overwriteFalse}, + }, + }, + wantNew: 0, + wantKept: 0, + wantSkipped: 1, + wantDeleted: 0, + description: "File exists and overwrite is false, should skip", + }, + { + name: "New file with overwrite:false that doesn't exist should be created", + diff: DiffResult{ + NewFiles: []FileStructure{ + {Destination: "new-file.txt", Content: "new content", Overwrite: &overwriteFalse}, + }, + }, + wantNew: 1, + wantKept: 0, + wantSkipped: 0, + wantDeleted: 0, + description: "File doesn't exist, should create even with overwrite:false", + }, + { + name: "Kept file with overwrite:false should be skipped", + diff: DiffResult{ + KeptFiles: []FileStructure{ + {Destination: "existing.txt", Content: "updated content", Overwrite: &overwriteFalse}, + }, + }, + wantNew: 0, + wantKept: 0, + wantSkipped: 1, + wantDeleted: 0, + description: "File exists and overwrite is false, should skip overwrite", + }, + { + name: "Kept file with overwrite:true should be overwritten", + diff: DiffResult{ + KeptFiles: []FileStructure{ + {Destination: "existing.txt", Content: "updated content", Overwrite: &overwriteTrue}, + }, + }, + wantNew: 0, + wantKept: 1, + wantSkipped: 0, + wantDeleted: 0, + description: "File exists but overwrite is true, should overwrite", + }, + { + name: "Kept file with overwrite:nil (default) should be overwritten", + diff: DiffResult{ + KeptFiles: []FileStructure{ + {Destination: "existing.txt", Content: "updated content", Overwrite: nil}, + }, + }, + wantNew: 0, + wantKept: 1, + wantSkipped: 0, + wantDeleted: 0, + description: "File exists and overwrite is nil (default true), should overwrite", + }, + { + name: "Mixed scenario", + diff: DiffResult{ + NewFiles: []FileStructure{ + {Destination: "brand-new.txt", Content: "new", Overwrite: nil}, + {Destination: "existing.txt", Content: "new", Overwrite: &overwriteFalse}, + }, + KeptFiles: []FileStructure{ + // Note: another-existing.txt doesn't exist on disk, so it stays in KeptFiles + // (only files that exist on disk AND have overwrite:false are skipped) + {Destination: "another-not-on-disk.txt", Content: "update", Overwrite: &overwriteFalse}, + }, + DeletedFiles: []FileStructure{ + {Destination: "to-delete.txt"}, + }, + }, + wantNew: 1, // brand-new.txt + wantKept: 1, // another-not-on-disk.txt stays in kept (doesn't exist on disk) + wantSkipped: 1, // existing.txt (exists on disk + overwrite:false) + wantDeleted: 1, // to-delete.txt + description: "Mixed scenario with various overwrite settings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := app.applySkipLogic(tt.diff) + + if len(result.NewFiles) != tt.wantNew { + t.Errorf("NewFiles count = %d, want %d (%s)", len(result.NewFiles), tt.wantNew, tt.description) + } + if len(result.KeptFiles) != tt.wantKept { + t.Errorf("KeptFiles count = %d, want %d (%s)", len(result.KeptFiles), tt.wantKept, tt.description) + } + if len(result.SkippedFiles) != tt.wantSkipped { + t.Errorf("SkippedFiles count = %d, want %d (%s)", len(result.SkippedFiles), tt.wantSkipped, tt.description) + } + if len(result.DeletedFiles) != tt.wantDeleted { + t.Errorf("DeletedFiles count = %d, want %d (%s)", len(result.DeletedFiles), tt.wantDeleted, tt.description) + } + }) + } +} + +func TestRenderWithOverwriteFalse(t *testing.T) { + // Create a temporary directory for output + tmpDir, err := os.MkdirTemp("", "structuresmith-overwrite-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create an existing file with specific content + existingFile := filepath.Join(tmpDir, "config.env") + originalContent := "ORIGINAL_SECRET=keep-me" + if err := os.WriteFile(existingFile, []byte(originalContent), 0o644); err != nil { + t.Fatalf("Failed to create existing file: %v", err) + } + + app := &Structuresmith{ + OutputDir: tmpDir, + } + + overwriteFalse := false + + // Try to render a file with overwrite: false + file := FileStructure{ + Destination: "config.env", + Content: "NEW_SECRET=overwrite-me", + Overwrite: &overwriteFalse, + } + + // First, simulate what would happen - the file exists and has overwrite:false + // so it should be skipped + diff := DiffResult{ + KeptFiles: []FileStructure{file}, + } + result := app.applySkipLogic(diff) + + if len(result.SkippedFiles) != 1 { + t.Errorf("Expected 1 skipped file, got %d", len(result.SkippedFiles)) + } + if len(result.KeptFiles) != 0 { + t.Errorf("Expected 0 kept files, got %d", len(result.KeptFiles)) + } + + // Verify the original file content is preserved + content, err := os.ReadFile(existingFile) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + if string(content) != originalContent { + t.Errorf("File content was modified, got %q, want %q", string(content), originalContent) + } +} + +func TestProcessDirectoryPreservesOverwrite(t *testing.T) { + // Create a temporary source directory with files + srcDir, err := os.MkdirTemp("", "structuresmith-src-*") + if err != nil { + t.Fatalf("Failed to create source temp dir: %v", err) + } + defer os.RemoveAll(srcDir) + + // Create a test file in source + testFile := filepath.Join(srcDir, "test.txt") + if err := os.WriteFile(testFile, []byte("test content"), 0o644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + app := &Structuresmith{} + + // Test that overwrite setting is propagated to processed files + overwriteFalse := false + directory := FileStructure{ + Source: srcDir, + Destination: "output/", + Overwrite: &overwriteFalse, + } + + files, err := app.processDirectory(directory) + if err != nil { + t.Fatalf("processDirectory() error = %v", err) + } + + if len(files) != 1 { + t.Fatalf("Expected 1 file, got %d", len(files)) + } + + if files[0].Overwrite == nil { + t.Error("Expected overwrite to be propagated, got nil") + } else if *files[0].Overwrite != overwriteFalse { + t.Errorf("Overwrite = %v, want %v", *files[0].Overwrite, overwriteFalse) + } +} diff --git a/cmd/structuresmith/config.go b/cmd/structuresmith/config.go index d8c41ac..b3e0aef 100644 --- a/cmd/structuresmith/config.go +++ b/cmd/structuresmith/config.go @@ -80,6 +80,9 @@ type FileStructure struct { // Permissions specifies the file mode for the destination file. // Accepts octal strings like "0755" or "0644". Defaults to "0644" if not specified. Permissions *FileMode `yaml:"permissions,omitempty"` + // Overwrite controls whether the file should be overwritten if it already exists. + // Defaults to true if not specified. Set to false to protect existing files. + Overwrite *bool `yaml:"overwrite,omitempty"` } // Template represents a template consisting of multiple files. diff --git a/cmd/structuresmith/config_test.go b/cmd/structuresmith/config_test.go index 609cdf2..0a1b0c5 100644 --- a/cmd/structuresmith/config_test.go +++ b/cmd/structuresmith/config_test.go @@ -563,3 +563,74 @@ permissions: "0644" }) } } + +func TestFileStructureWithOverwrite(t *testing.T) { + tests := []struct { + name string + yaml string + want *bool + wantErr bool + }{ + { + name: "File with overwrite true", + yaml: ` +destination: "readme.md" +content: "# README" +overwrite: true +`, + want: func() *bool { b := true; return &b }(), + wantErr: false, + }, + { + name: "File with overwrite false", + yaml: ` +destination: "config.env" +content: "SECRET=changeme" +overwrite: false +`, + want: func() *bool { b := false; return &b }(), + wantErr: false, + }, + { + name: "File without overwrite (should be nil, defaults to true)", + yaml: ` +destination: "readme.md" +content: "# README" +`, + want: nil, + wantErr: false, + }, + { + name: "File with both permissions and overwrite", + yaml: ` +destination: "config.env" +content: "SECRET=changeme" +permissions: "0600" +overwrite: false +`, + want: func() *bool { b := false; return &b }(), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var file FileStructure + err := yaml.Unmarshal([]byte(tt.yaml), &file) + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalYAML() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.want == nil && file.Overwrite != nil { + t.Errorf("Expected nil overwrite, got %v", *file.Overwrite) + } + if tt.want != nil { + if file.Overwrite == nil { + t.Errorf("Expected overwrite %v, got nil", *tt.want) + } else if *file.Overwrite != *tt.want { + t.Errorf("Overwrite = %v, want %v", *file.Overwrite, *tt.want) + } + } + }) + } +} diff --git a/cmd/structuresmith/lock.go b/cmd/structuresmith/lock.go index df1c78c..c13aa61 100644 --- a/cmd/structuresmith/lock.go +++ b/cmd/structuresmith/lock.go @@ -120,6 +120,7 @@ const ( StatusNew FileStatus = "New" StatusDeleted FileStatus = "Deleted" StatusKept FileStatus = "Kept" + StatusSkipped FileStatus = "Skipped" ) // DiffResult represents the result of diffing FileStructures against AnvilLock entries. @@ -127,6 +128,7 @@ type DiffResult struct { NewFiles []FileStructure // Files present in FileStructures but not in AnvilLock. DeletedFiles []FileStructure // Files present in AnvilLock but not in FileStructures. KeptFiles []FileStructure // Files present in both AnvilLock and FileStructures. + SkippedFiles []FileStructure // Files that exist on disk and have overwrite: false. } func (d DiffResult) String() string { @@ -142,6 +144,9 @@ func (d DiffResult) String() string { for _, file := range d.KeptFiles { fileMap[file.Destination] = StatusKept } + for _, file := range d.SkippedFiles { + fileMap[file.Destination] = StatusSkipped + } // Sort the keys (file paths) keys := make([]string, 0, len(fileMap)) @@ -175,6 +180,8 @@ func getColorAndPrefix(status FileStatus) string { return color.New(color.FgRed).Sprintf("delete:") case StatusKept: return color.New(color.FgYellow).Sprintf("overwrite:") + case StatusSkipped: + return color.New(color.FgCyan).Sprintf("skip:") default: return "n/a: " }