Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3c1ade0
chore: Add AGENTS.md
jdehaan Mar 20, 2026
562f5d5
fix: Eliminate `log.Fatalf` - return Errors Instead
jdehaan Mar 20, 2026
201655a
refactor: Reduce Duplication in Command Types
jdehaan Mar 20, 2026
320138d
fix: Inject Exec via constructors (Testability)
jdehaan Mar 20, 2026
52b19e4
fix: Add tests for Run commands
jdehaan Mar 20, 2026
3720805
refactor: Replace `fmt.Printf` with structured output
jdehaan Mar 20, 2026
b62c3cc
fix: Add tests using mocks for capturing output
jdehaan Mar 20, 2026
0604709
refactor: Introduce `CoverageChecker` and inject logger
jdehaan Mar 20, 2026
b22ce60
fix: Add tests for coverage check
jdehaan Mar 20, 2026
088b13e
fix: Inject filesystem for tests and add command tests
jdehaan Mar 20, 2026
8dd1b4e
chore: Use go 1.25
jdehaan Mar 20, 2026
e6218c0
feat: Add flag for data race detection
jdehaan Mar 20, 2026
151367b
ci: Add check coverage with 90%
jdehaan Mar 20, 2026
d11f53f
fix: Remove spurious comment
jdehaan Mar 20, 2026
4e12d9c
chore: Update AGENTS.md
jdehaan Mar 20, 2026
e674792
fix: Use cobra's cmd.OutOrStdout
jdehaan Mar 20, 2026
46a618a
fix: Improve test coverage
jdehaan Mar 20, 2026
eb93074
refactor: Inject Exec into cmd for full testability
jdehaan Mar 20, 2026
2d80314
chore: Improve test coverage
jdehaan Mar 20, 2026
c017285
fix: Deterministic tests by injecting timestamp
jdehaan Mar 20, 2026
75b12ec
fix: Close log file handle properly
jdehaan Mar 20, 2026
76f50e4
fix: Defensive programming for Marshal
jdehaan Mar 20, 2026
39beee0
feat: Add structured exit codes and summary
jdehaan Mar 20, 2026
6064cd3
docs: Update test documentation and add godoc comments
jdehaan Mar 20, 2026
80fae25
fix: Increase test coverage
jdehaan Mar 20, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,6 @@ jobs:

- name: Test
run: make test

- name: Check coverage
run: make check-coverage
92 changes: 92 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Project Guidelines

## Overview

CLI tool for managing local backups using `rsync` as the engine.
Built in Go with `cobra` for CLI, `afero` for filesystem abstraction, and YAML for configuration. Local-only — no remote rsync support.

## Code Style

- Go 1.25; follow idiomatic Go conventions
- Format with `go fmt`; lint with `golangci-lint` (config in `.golangci.yml`)
- All linters enabled by default — check `.golangci.yml` for disabled ones
- Keep packages focused: `cmd/` for CLI wiring, `internal/` for core logic
- Prefer dependency injection over global state for testability
- Use interfaces at consumption boundaries (see `internal/exec.go`, `internal/job_command.go`)
- All output in commands routed through `cmd.OutOrStdout()` or injected `io.Writer` — never raw `fmt.Printf`
- All commands use `RunE` with wrapped errors

## Architecture

```
backup/
main.go # Entrypoint — calls cmd.BuildRootCommand().Execute()
cmd/ # Cobra commands: list, run, simulate, config (show/validate), check-coverage, version
root.go # BuildRootCommand() / BuildRootCommandWithFs(fs) — injects afero.Fs
test/ # Cobra command integration tests
internal/ # Core logic: config, job execution, rsync wrapper, coverage checker
test/ # Unit tests + mockery-generated mocks
```

### Key Types & Interfaces

- **`Exec`** (interface): Command execution abstraction (`OsExec` for real, `MockExec` for tests)
- **`JobCommand`** (interface): `Run(job Job) JobStatus` + `GetVersionInfo()` — implemented by `ListCommand`, `SimulateCommand`, `SyncCommand`
- **`SharedCommand`** (struct): Base for all commands — holds `BinPath`, `BaseLogPath`, `Shell Exec`, `Output io.Writer`
- **`CoverageChecker`** (struct): Analyzes path coverage with injected `*log.Logger` and `afero.Fs`
- **`Config`**: YAML-based (`Config`, `Job`, `Path`, variables with `${var}` substitution)

### Dependency Injection

- `Exec` injected into all command constructors (`NewListCommand`, `NewSimulateCommand`, `NewSyncCommand`)
- `io.Writer` injected into `SharedCommand` for output capture
- `afero.Fs` injected into `BuildRootCommandWithFs()` → `buildCheckCoverageCommand(fs)`
- `*log.Logger` injected into `CoverageChecker` and `Config.Apply()`
- Commands use `cmd.OutOrStdout()` for testable output

## Build and Test

```sh
make build # Build to dist/backup
make test # go test -race ./... -v
make lint # golangci-lint run ./...
make lint-fix # Auto-fix lint issues
make format # go fmt ./...
make tidy # gofmt -s + go mod tidy
make sanity-check # format + clean + tidy
make check-coverage # Fail if coverage < 90%
make report-coverage # Generate HTML coverage report
```

## Testing Conventions

- See `TESTING_GUIDE.md` for patterns and examples
- Use **dependency injection** — inject interfaces, not concrete types
- **Mocks**: Generated with [mockery](https://github.com/vektra/mockery) (config: `.mockery.yml`)
- Mock files live in `internal/test/` as `mock_<interface>_test.go`
- Mock structs named `Mock<Interface>` (e.g., `MockExec`, `MockJobCommand`)
- See `MOCKERY_INTEGRATION.md` for setup details
- Use `testify` for assertions (`require` / `assert`)
- Test files live in `<package>/test/` subdirectories
- Prefer table-driven tests for multiple input scenarios
- Use `afero.NewMemMapFs()` in tests — never hit the real filesystem
- Use `bytes.Buffer` or `io.Discard` for output capture in tests
- CI enforces coverage threshold via `make check-coverage`

## CI Pipeline

CI runs on every push/PR to `main` (`.github/workflows/go.yml`):
1. Sanity check (format + clean + mod tidy)
2. Lint (golangci-lint)
3. Build
4. Test (with `-race` flag)
5. Coverage threshold enforcement (90%)

## Conventions

- No remote rsync — only locally mounted paths
- Job-level granularity: each backup job can be listed, simulated, or run independently
- Dry-run/simulate mode available for all operations
- Logging goes to both an injected `io.Writer` (user output) and `*log.Logger` (file logging) under `logs/`
- Custom YAML unmarshaling handles job defaults (see `internal/job.go`)
- CI runs sanity checks, lint, and build on every push/PR (`.github/workflows/go.yml`)
114 changes: 114 additions & 0 deletions MOCKERY_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Mockery Integration Guide

This document explains how mockery is integrated for generating mocks from interfaces.

## Installation

```bash
go install github.com/vektra/mockery/v3@latest
```

## Configuration

The project uses `.mockery.yml` to control mock generation:

```yaml
all: false
dir: '{{.InterfaceDir}}/test'
filename: mock_{{.InterfaceName | lower}}_test.go
force-file-write: true
formatter: goimports
generate: true
include-auto-generated: false
log-level: info
structname: 'Mock{{.InterfaceName}}'
pkgname: 'internal_test'
recursive: false
template: testify
packages:
backup-rsync/backup/internal:
interfaces:
Exec:
JobCommand:
```

Key points:
- **Output directory**: `<InterfaceDir>/test/` (alongside other test files)
- **Filename**: `mock_<interface>_test.go`
- **Struct naming**: `Mock<Interface>` (e.g., `MockExec`, `MockJobCommand`)
- **Package**: `internal_test` (external test package)
- **Template**: `testify` for expectation-based mocking

## Generated Mocks

| Mock | Source Interface | File |
|---|---|---|
| `MockExec` | `Exec` | `backup/internal/test/mock_exec_test.go` |
| `MockJobCommand` | `JobCommand` | `backup/internal/test/mock_jobcommand_test.go` |

## Usage Examples

### MockJobCommand — Testing Config.Apply

```go
func TestConfigApply_VersionInfoSuccess(t *testing.T) {
mockCmd := NewMockJobCommand(t)
var output bytes.Buffer
logger := log.New(&bytes.Buffer{}, "", 0)

cfg := Config{
Jobs: []Job{
{Name: "job1", Source: "/src/", Target: "/dst/", Enabled: true},
{Name: "job2", Source: "/src2/", Target: "/dst2/", Enabled: false},
},
}

mockCmd.EXPECT().GetVersionInfo().Return("rsync version 3.2.3", "/usr/bin/rsync", nil).Once()
mockCmd.EXPECT().Run(mock.AnythingOfType("internal.Job")).Return(Success).Once()

err := cfg.Apply(mockCmd, logger, &output)
require.NoError(t, err)
assert.Contains(t, output.String(), "Status [job1]: SUCCESS")
}
```

### MockExec — Testing Command Execution

```go
func TestSyncCommand_Run_Success(t *testing.T) {
mockExec := NewMockExec(t)
var output bytes.Buffer

cmd := NewSyncCommand("/usr/bin/rsync", "/tmp/logs", mockExec, &output)
job := Job{Name: "docs", Source: "/src/", Target: "/dst/", Enabled: true, Delete: true}

mockExec.EXPECT().Execute("/usr/bin/rsync", mock.Anything).Return([]byte("done"), nil).Once()

status := cmd.Run(job)
assert.Equal(t, Success, status)
}
```

### Testing Disabled Jobs (no mock expectations needed)

```go
func TestJobApply_DisabledJob(t *testing.T) {
mockCmd := NewMockJobCommand(t)
disabledJob := Job{Name: "skip_me", Enabled: false}

// No expectations set — Run should NOT be called
status := disabledJob.Apply(mockCmd)
assert.Equal(t, Skipped, status)
// MockJobCommand automatically verifies Run was not called
}
```

## Regenerating Mocks

When interfaces change, regenerate with:

```bash
mockery
```

This updates all mocks according to `.mockery.yml`. Generated files are committed to the repository.
16 changes: 14 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
# Build command with common flags
BUILD_CMD = CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -tags=prod
PACKAGE = ./backup/main.go
COVERAGE_THRESHOLD = 98

.PHONY: build clean test lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean
.PHONY: build clean test lint tidy checksums release sanity-check check-mod-tidy lint-config-check lint-fix format check-clean check-coverage

format:
go fmt ./...
Expand Down Expand Up @@ -33,7 +34,7 @@ sanity-check: format check-clean check-mod-tidy
@echo "OK: All sanity checks passed."

test:
go test ./... -v
go test -race ./... -v

tidy:
gofmt -s -w .
Expand Down Expand Up @@ -73,6 +74,17 @@ report-size: build
go install github.com/Zxilly/go-size-analyzer/cmd/gsa@latest
gsa --web --listen=":8910" --open dist/backup

check-coverage:
@go test ./... -count=1 -coverprofile=/tmp/coverage.out -coverpkg=./backup/...
@COVERAGE=$$(go tool cover -func=/tmp/coverage.out | grep '^total:' | awk '{print int($$3)}'); \
echo "Total coverage: $${COVERAGE}%"; \
if [ "$${COVERAGE}" -lt "$(COVERAGE_THRESHOLD)" ]; then \
echo "FAIL: Coverage $${COVERAGE}% is below threshold $(COVERAGE_THRESHOLD)%"; \
exit 1; \
else \
echo "OK: Coverage $${COVERAGE}% meets threshold $(COVERAGE_THRESHOLD)%"; \
fi

report-coverage:
@mkdir -p coverage
@go test ./... -count=1 -coverprofile=coverage/coverage.out -coverpkg=./backup/...
Expand Down
Loading
Loading