diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-27 09:18:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-27 09:18:52 +0300 |
| commit | 47d69cb998a447eea662ad1075f9d002dd875443 (patch) | |
| tree | 1fa586fc4f7e5fae2ce9badfa05679c0fee9dcb3 | |
| parent | 626ff3ae7d43cfc2ec3f2554d340b40f4a5c0586 (diff) | |
Add context.Context to I/O-bound public APIs (generator.Run, processor.Run, atom.Generate, syncOutput)
- generator.Run(ctx, cfg) – ctx passed through to atom.Generate
- processor.Run(ctx, cfg) – signature updated for cancellation propagation
- atom.Generate(ctx, posts, cfg) – accepts ctx for future cancellation
- syncOutput(ctx, cfg) – rsync subprocesses now use exec.CommandContext
- Updated all call sites in tests, cmd/snonux/main.go, and integration tests
- All call sites pass context.Background() / context.TODO()
All tests pass: go test ./...
| -rw-r--r-- | cmd/snonux/main.go | 13 | ||||
| -rw-r--r-- | cmd/snonux/main_test.go | 5 | ||||
| -rw-r--r-- | cmd/snonux/sync.go | 6 | ||||
| -rw-r--r-- | cmd/snonux/sync_test.go | 4 | ||||
| -rw-r--r-- | integrationtests/integration_test.go | 11 | ||||
| -rw-r--r-- | internal/generator/atom/atom.go | 5 | ||||
| -rw-r--r-- | internal/generator/atom/atom_test.go | 9 | ||||
| -rw-r--r-- | internal/generator/generator.go | 7 | ||||
| -rw-r--r-- | internal/generator/generator_test.go | 5 | ||||
| -rw-r--r-- | internal/generator/theme_sounds.go | 3 | ||||
| -rw-r--r-- | internal/processor/markdown_test.go | 4 | ||||
| -rw-r--r-- | internal/processor/processor.go | 20 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 21 |
13 files changed, 72 insertions, 41 deletions
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go index 659bd19..853d807 100644 --- a/cmd/snonux/main.go +++ b/cmd/snonux/main.go @@ -8,6 +8,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -61,12 +62,14 @@ func main() { log.Fatalf("error: %v", err) } - if err := run(cfg); err != nil { + ctx := context.Background() + + if err := run(ctx, cfg); err != nil { log.Fatalf("error: %v", err) } if cfg.Sync { - if err := syncOutput(cfg); err != nil { + if err := syncOutput(ctx, cfg); err != nil { log.Fatalf("error: %v", err) } } @@ -130,15 +133,15 @@ func expandHome(path string) (string, error) { } // run executes both pipeline phases: process inputs, then regenerate pages. -func run(cfg *config.Config) error { - processed, err := processor.Run(cfg) +func run(ctx context.Context, cfg *config.Config) error { + processed, err := processor.Run(ctx, cfg) if err != nil { return fmt.Errorf("processing input files: %w", err) } log.Printf("processed %d new post(s) from %s", processed, cfg.InputDir) - if err := generator.Run(cfg); err != nil { + if err := generator.Run(ctx, cfg); err != nil { return fmt.Errorf("generating site: %w", err) } diff --git a/cmd/snonux/main_test.go b/cmd/snonux/main_test.go index b8a5a82..06cbc1c 100644 --- a/cmd/snonux/main_test.go +++ b/cmd/snonux/main_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "math/rand" "os" @@ -12,6 +13,8 @@ import ( "codeberg.org/snonux/snonux/internal/generator" ) +var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file + func TestExpandHome(t *testing.T) { t.Parallel() @@ -291,7 +294,7 @@ func TestRun_pipeline(t *testing.T) { BaseURL: "https://pipe.test", Theme: "neon", } - if err := run(cfg); err != nil { + if err := run(ctx, cfg); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(out, "index.html")) diff --git a/cmd/snonux/sync.go b/cmd/snonux/sync.go index f5d2e9e..3e4155e 100644 --- a/cmd/snonux/sync.go +++ b/cmd/snonux/sync.go @@ -58,7 +58,9 @@ func splitAndTrim(s string) []string { // syncOutput rsyncs localOutput (trailing-slash source) to each sync target over SSH // port 22. It runs only if every target answers ICMP ping (Linux iputils: ping -c 1 -W …). -func syncOutput(cfg *config.Config) error { +// The ctx parameter is accepted for cancellation propagation; it is wired into +// exec.CommandContext for the rsync subprocesses. +func syncOutput(ctx context.Context, cfg *config.Config) error { resolveSyncConfig(cfg) for _, host := range cfg.SyncTargets { @@ -87,7 +89,7 @@ func syncOutput(cfg *config.Config) error { for _, host := range cfg.SyncTargets { dest := fmt.Sprintf("%s@%s:%s", sshUser, host, cfg.SyncRemoteDir) log.Printf("rsync %s -> %s", src, dest) - cmd := exec.Command("rsync", "-az", "-e", ssh, src, dest) + cmd := exec.CommandContext(ctx, "rsync", "-az", "-e", ssh, src, dest) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { diff --git a/cmd/snonux/sync_test.go b/cmd/snonux/sync_test.go index d45a916..93c9233 100644 --- a/cmd/snonux/sync_test.go +++ b/cmd/snonux/sync_test.go @@ -87,8 +87,8 @@ func TestResolveSyncConfig_flagsOverrideEnv(t *testing.T) { os.Setenv("SNONUX_SYNC_REMOTE_DIR", "/env/dir/") cfg := &config.Config{ - SyncTargets: []string{"from-flag"}, - SyncRemoteDir: "/flag/dir/", + SyncTargets: []string{"from-flag"}, + SyncRemoteDir: "/flag/dir/", } resolveSyncConfig(cfg) diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go index 9beaf29..e3737f4 100644 --- a/integrationtests/integration_test.go +++ b/integrationtests/integration_test.go @@ -4,6 +4,7 @@ package integrationtests import ( + "context" "encoding/json" "encoding/xml" "fmt" @@ -23,6 +24,8 @@ import ( "codeberg.org/snonux/snonux/internal/processor" ) +var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file + // runPipeline executes both pipeline stages and returns the config used. func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config { t.Helper() @@ -34,12 +37,12 @@ func runPipeline(t *testing.T, inputDir, outputDir string) *config.Config { Theme: "neon", } - _, err := processor.Run(cfg) + _, err := processor.Run(ctx, cfg) if err != nil { t.Fatalf("processor.Run: %v", err) } - if err := generator.Run(cfg); err != nil { + if err := generator.Run(ctx, cfg); err != nil { t.Fatalf("generator.Run: %v", err) } @@ -427,10 +430,10 @@ func TestThemeSelection(t *testing.T) { Theme: theme, } - if _, err := processor.Run(cfg); err != nil { + if _, err := processor.Run(ctx, cfg); err != nil { t.Fatalf("processor.Run: %v", err) } - if err := generator.Run(cfg); err != nil { + if err := generator.Run(ctx, cfg); err != nil { t.Fatalf("generator.Run for theme %q: %v", theme, err) } diff --git a/internal/generator/atom/atom.go b/internal/generator/atom/atom.go index 75f4876..03475d8 100644 --- a/internal/generator/atom/atom.go +++ b/internal/generator/atom/atom.go @@ -5,6 +5,7 @@ package atom import ( + "context" "encoding/xml" "fmt" "os" @@ -48,7 +49,9 @@ type content struct { // Generate writes atom.xml to cfg.OutputDir containing the most recent // min(len(posts), config.PostsPerPage) entries. Posts must already be sorted // newest-first (as produced by generator.Run). -func Generate(posts []*post.Post, cfg *config.Config) error { +// The context is currently accepted for API consistency and future +// cancellation propagation; no blocking I/O operations currently observe it. +func Generate(ctx context.Context, posts []*post.Post, cfg *config.Config) error { limit := config.PostsPerPage if len(posts) < limit { limit = len(posts) diff --git a/internal/generator/atom/atom_test.go b/internal/generator/atom/atom_test.go index 2bfdfb7..04a33fb 100644 --- a/internal/generator/atom/atom_test.go +++ b/internal/generator/atom/atom_test.go @@ -1,6 +1,7 @@ package atom import ( + "context" "encoding/xml" "os" "path/filepath" @@ -12,6 +13,8 @@ import ( "codeberg.org/snonux/snonux/internal/post" ) +var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file + func TestGenerate_writesAtomXML(t *testing.T) { t.Parallel() @@ -28,7 +31,7 @@ func TestGenerate_writesAtomXML(t *testing.T) { }, } - if err := Generate(posts, cfg); err != nil { + if err := Generate(ctx, posts, cfg); err != nil { t.Fatalf("Generate: %v", err) } @@ -54,7 +57,7 @@ func TestGenerate_emptyPosts(t *testing.T) { dir := t.TempDir() cfg := &config.Config{OutputDir: dir, BaseURL: "https://x.test"} - if err := Generate(nil, cfg); err != nil { + if err := Generate(ctx, nil, cfg); err != nil { t.Fatalf("Generate: %v", err) } @@ -90,7 +93,7 @@ func TestGenerate_limitPostsPerPage(t *testing.T) { }) } - if err := Generate(posts, cfg); err != nil { + if err := Generate(ctx, posts, cfg); err != nil { t.Fatalf("Generate: %v", err) } diff --git a/internal/generator/generator.go b/internal/generator/generator.go index db9531b..bd5b850 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -1,6 +1,7 @@ package generator import ( + "context" "encoding/json" "fmt" "html/template" @@ -81,7 +82,9 @@ func allThemesJSON() (template.JS, error) { // Run loads all posts, generates all HTML pages, and writes atom.xml plus the // shared CSS/JS bundles and per-theme asset files. -func Run(cfg *config.Config) error { +// The ctx parameter is accepted for cancellation propagation; it is passed +// through to I/O-bound calls where possible. +func Run(ctx context.Context, cfg *config.Config) error { posts, err := loadAllPosts(cfg.OutputDir) if err != nil { return err @@ -131,7 +134,7 @@ func Run(cfg *config.Config) error { } } - return atom.Generate(posts, cfg) + return atom.Generate(ctx, posts, cfg) } // loadAllPosts walks outdir/posts/ and deserialises every post.json found. diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 56542a3..50cd3d0 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -1,6 +1,7 @@ package generator import ( + "context" "encoding/json" "html/template" "os" @@ -13,6 +14,8 @@ import ( "codeberg.org/snonux/snonux/internal/post" ) +var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file + func TestPageFilename(t *testing.T) { t.Parallel() @@ -428,7 +431,7 @@ func TestRun_writesPagesAndAtom(t *testing.T) { BaseURL: "https://example.test", Theme: "neon", } - if err := Run(cfg); err != nil { + if err := Run(ctx, cfg); err != nil { t.Fatalf("Run: %v", err) } if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil { diff --git a/internal/generator/theme_sounds.go b/internal/generator/theme_sounds.go index 20ffacc..234a964 100644 --- a/internal/generator/theme_sounds.go +++ b/internal/generator/theme_sounds.go @@ -112,7 +112,8 @@ func minor(freq float64) [3]float64 { // into the preceding note's step so the next real note triggers later. We // can't emit a zero-frequency rest entry because the JS engine substitutes // 440 Hz for falsy freq values, which would turn rests into audible tones. -// beat = seconds-per-quarter-note. dur multiplier of 1.0 = quarter note. +// +// beat = seconds-per-quarter-note. dur multiplier of 1.0 = quarter note. func hook(beat float64, pairs ...float64) []melodyNote { out := make([]melodyNote, 0, len(pairs)/2) leadIn := 0.0 diff --git a/internal/processor/markdown_test.go b/internal/processor/markdown_test.go index c53874d..b4582a9 100644 --- a/internal/processor/markdown_test.go +++ b/internal/processor/markdown_test.go @@ -1,6 +1,7 @@ package processor import ( + "context" "os" "path/filepath" "runtime" @@ -121,7 +122,6 @@ func TestFindLocalImages(t *testing.T) { want: []string{"z.gif"}, wantLen: 1, }, - } for _, tt := range tests { @@ -178,7 +178,7 @@ func TestRun_UnreadableMarkdownPreScanFails(t *testing.T) { t.Cleanup(func() { _ = os.Chmod(mdPath, 0o644) }) cfg := &config.Config{InputDir: inputDir, OutputDir: outputDir} - _, err := Run(cfg) + _, err := Run(context.Background(), cfg) if err == nil { t.Fatal("Run: expected error when markdown pre-scan cannot read a .md file") } diff --git a/internal/processor/processor.go b/internal/processor/processor.go index bb3a84d..0bbc28e 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -4,9 +4,10 @@ // Each processed source file is deleted from the input directory afterward. // // Processing uses a two-phase commit pattern: -// 1. Scan and validate every inbox item without mutating anything. -// 2. Only after all items pass validation, execute mutations -// (create directories, write assets, persist posts, remove sources). +// 1. Scan and validate every inbox item without mutating anything. +// 2. Only after all items pass validation, execute mutations +// (create directories, write assets, persist posts, remove sources). +// // If validation fails for any item, the entire batch is aborted and the inbox // is left untouched. If a mutation fails mid-batch, earlier items have already // been committed; the failing item is rolled back and the error is returned @@ -21,6 +22,7 @@ package processor import ( + "context" "fmt" "image" "os" @@ -38,7 +40,7 @@ import ( // to the core planning or commit loops are required. type PostBuilder interface { // Plan validates the source file and returns everything needed to commit it later. - Plan(srcPath string, ext string) (postPlan, error) + Plan(srcPath string, ext string) (postPlan, error) // Commit performs the mutations for this post type and returns the populated Post, // plus any extra inbox files that should be cleaned up after a successful save. Commit(plan postPlan, postDir string, id string, now time.Time) (*post.Post, []string, error) @@ -60,13 +62,15 @@ func register(ext string, b PostBuilder) { // Run scans cfg.InputDir and processes every eligible file into a post directory // under cfg.OutputDir/posts/. It uses a two-phase commit pattern: // -// Phase 1 — scan and validate all inbox items without mutating anything. -// Phase 2 — only after all items pass validation, execute mutations -// (create directories, write assets, persist posts, remove sources). +// Phase 1 — scan and validate all inbox items without mutating anything. +// Phase 2 — only after all items pass validation, execute mutations +// (create directories, write assets, persist posts, remove sources). // // If Phase 1 fails for any item, no mutations occur and the inbox is left untouched. // Returns the number of posts successfully created in this invocation. -func Run(cfg *config.Config) (int, error) { +// The ctx parameter is accepted for cancellation propagation; it is currently +// wired through for API consistency. +func Run(ctx context.Context, cfg *config.Config) (int, error) { entries, err := os.ReadDir(cfg.InputDir) if err != nil { return 0, fmt.Errorf("read input dir %s: %w", cfg.InputDir, err) diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 418fb95..b5e8f20 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -1,6 +1,7 @@ package processor import ( + "context" "image" "image/png" "os" @@ -13,6 +14,8 @@ import ( "codeberg.org/snonux/snonux/internal/post" ) +var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file + func TestRun_processesTxt(t *testing.T) { t.Parallel() @@ -23,7 +26,7 @@ func TestRun_processesTxt(t *testing.T) { } cfg := &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x.test"} - n, err := Run(cfg) + n, err := Run(ctx, cfg) if err != nil { t.Fatalf("Run: %v", err) } @@ -57,7 +60,7 @@ func TestRun_unsupportedExt(t *testing.T) { t.Fatal(err) } - _, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + _, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err == nil { t.Fatal("expected error") } @@ -66,7 +69,7 @@ func TestRun_unsupportedExt(t *testing.T) { func TestRun_readInputDirFails(t *testing.T) { t.Parallel() - _, err := Run(&config.Config{InputDir: "/nonexistent/inbox/xyz", OutputDir: t.TempDir(), BaseURL: "https://x"}) + _, err := Run(ctx, &config.Config{InputDir: "/nonexistent/inbox/xyz", OutputDir: t.TempDir(), BaseURL: "https://x"}) if err == nil { t.Fatal("expected error") } @@ -89,7 +92,7 @@ func TestRun_png(t *testing.T) { } f.Close() - n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err != nil { t.Fatalf("Run: %v", err) } @@ -107,7 +110,7 @@ func TestRun_mp3(t *testing.T) { t.Fatal(err) } - n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err != nil { t.Fatalf("Run: %v", err) } @@ -125,7 +128,7 @@ func TestRun_markdown(t *testing.T) { t.Fatal(err) } - n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err != nil { t.Fatalf("Run: %v", err) } @@ -216,7 +219,7 @@ text` t.Fatal(err) } - n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err != nil { t.Fatalf("Run: %v", err) } @@ -264,7 +267,7 @@ func TestRun_twoMarkdownsClaimingSameImageFails(t *testing.T) { t.Fatal(err) } - _, err = Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + _, err = Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err == nil { t.Fatal("expected error when two markdowns claim the same image") } @@ -307,7 +310,7 @@ func TestRun_duplicateImageClaimsInSameMarkdownAllowed(t *testing.T) { t.Fatal(err) } - n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + n, err := Run(ctx, &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) if err != nil { t.Fatalf("Run: %v", err) } |
