Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions anvil.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions cmd/structuresmith/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down
269 changes: 269 additions & 0 deletions cmd/structuresmith/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions cmd/structuresmith/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading