diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-10 10:43:51 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-10 10:43:51 +0300 |
| commit | fc28cc5196900f0c8ae446269294da42d5488c95 (patch) | |
| tree | 57313f0d9bd17e722150be53fe230a38a37dfddb | |
| parent | a2cf4a2d5b59fb6e445f8b3f5bfbdace42b6a5bf (diff) | |
Release v0.1.3v0.1.3
Testable CLI flags; version package under internal/version; broad tests for
atom, generator, post, processor, and cmd—overall coverage ~85%.
Made-with: Cursor
| -rw-r--r-- | cmd/snonux/main.go | 67 | ||||
| -rw-r--r-- | cmd/snonux/main_test.go | 162 | ||||
| -rw-r--r-- | internal/generator/atom/atom_test.go | 107 | ||||
| -rw-r--r-- | internal/generator/generator_test.go | 68 | ||||
| -rw-r--r-- | internal/post/post_test.go | 48 | ||||
| -rw-r--r-- | internal/processor/processor_test.go | 186 | ||||
| -rw-r--r-- | internal/version/version.go (renamed from internal/version.go) | 2 |
7 files changed, 619 insertions, 21 deletions
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go index 6b6d0bb..1d00646 100644 --- a/cmd/snonux/main.go +++ b/cmd/snonux/main.go @@ -8,54 +8,81 @@ package main import ( + "errors" "flag" "fmt" + "io" "log" "math/rand" "os" "path/filepath" "strings" - "codeberg.org/snonux/snonux/internal" "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/generator" "codeberg.org/snonux/snonux/internal/processor" + "codeberg.org/snonux/snonux/internal/version" +) + +// cliMode tells main whether to run the pipeline or print and exit. +type cliMode int + +const ( + modeRun cliMode = iota + modeVersion + modeListThemes ) func main() { - cfg, err := parseFlags() + cfg, mode, err := parseFlags(os.Args[1:]) if err != nil { log.Fatalf("error: %v", err) } + switch mode { + case modeVersion: + fmt.Println(version.Version) + return + case modeListThemes: + fmt.Println(strings.Join(generator.ListThemes(), "\n")) + return + } + if err := run(cfg); err != nil { log.Fatalf("error: %v", err) } } +// errParseFlags is returned when flag parsing fails (e.g. unknown flag). +var errParseFlags = errors.New("flag parse error") + // parseFlags reads CLI flags and returns a validated Config. // Special theme value "random" picks a theme at random from the registry. -func parseFlags() (*config.Config, error) { +func parseFlags(args []string) (*config.Config, cliMode, error) { cfg := &config.Config{} + fs := flag.NewFlagSet("snonux", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var showVersion bool - flag.BoolVar(&showVersion, "version", false, "print version and exit (-version, --version)") - flag.BoolVar(&showVersion, "v", false, "print version and exit (shorthand for -version)") - listThemes := flag.Bool("list-themes", false, "print all available theme names and exit") + fs.BoolVar(&showVersion, "version", false, "print version and exit (-version, --version)") + fs.BoolVar(&showVersion, "v", false, "print version and exit (shorthand for -version)") + listThemes := fs.Bool("list-themes", false, "print all available theme names and exit") + + fs.StringVar(&cfg.InputDir, "input", "./inbox", "directory containing new source files to process") + fs.StringVar(&cfg.OutputDir, "output", "~/git/snonux.foo/dist", "root directory for generated static site output") + fs.StringVar(&cfg.BaseURL, "base-url", "https://snonux.foo", "canonical base URL used in Atom feed links") + fs.StringVar(&cfg.Theme, "theme", "random", "visual theme name, or \"random\" to pick one at random") - flag.StringVar(&cfg.InputDir, "input", "./inbox", "directory containing new source files to process") - flag.StringVar(&cfg.OutputDir, "output", "~/git/snonux.foo/dist", "root directory for generated static site output") - flag.StringVar(&cfg.BaseURL, "base-url", "https://snonux.foo", "canonical base URL used in Atom feed links") - flag.StringVar(&cfg.Theme, "theme", "random", "visual theme name, or \"random\" to pick one at random") - flag.Parse() + if err := fs.Parse(args); err != nil { + return nil, modeRun, fmt.Errorf("%w: %w", errParseFlags, err) + } if showVersion { - fmt.Println(version.Version) - os.Exit(0) + return nil, modeVersion, nil } if *listThemes { - fmt.Println(strings.Join(generator.ListThemes(), "\n")) - os.Exit(0) + return nil, modeListThemes, nil } // Resolve the special "random" value before any further validation. @@ -69,23 +96,23 @@ func parseFlags() (*config.Config, error) { cfg.InputDir, err = expandHome(cfg.InputDir) if err != nil { - return nil, fmt.Errorf("input dir: %w", err) + return nil, modeRun, fmt.Errorf("input dir: %w", err) } cfg.OutputDir, err = expandHome(cfg.OutputDir) if err != nil { - return nil, fmt.Errorf("output dir: %w", err) + return nil, modeRun, fmt.Errorf("output dir: %w", err) } if err := ensureDir(cfg.InputDir); err != nil { - return nil, fmt.Errorf("input dir: %w", err) + return nil, modeRun, fmt.Errorf("input dir: %w", err) } if err := ensureDir(cfg.OutputDir); err != nil { - return nil, fmt.Errorf("output dir: %w", err) + return nil, modeRun, fmt.Errorf("output dir: %w", err) } - return cfg, nil + return cfg, modeRun, nil } // expandHome replaces a leading ~ with the current user's home directory. diff --git a/cmd/snonux/main_test.go b/cmd/snonux/main_test.go new file mode 100644 index 0000000..212efc8 --- /dev/null +++ b/cmd/snonux/main_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/generator" +) + +func TestExpandHome(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + + got, err := expandHome(filepath.Join("~", "snonux-test-sub")) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, "snonux-test-sub") + if got != want { + t.Fatalf("got %q; want %q", got, want) + } + + got, err = expandHome("/no/tilde") + if err != nil || got != "/no/tilde" { + t.Fatalf("got %q err %v", got, err) + } +} + +func TestEnsureDir_createsAndRejectsFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sub := filepath.Join(dir, "newdir") + if err := ensureDir(sub); err != nil { + t.Fatal(err) + } + if st, err := os.Stat(sub); err != nil || !st.IsDir() { + t.Fatal("not a dir") + } + if err := ensureDir(sub); err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(dir, "file") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureDir(filePath); err == nil { + t.Fatal("expected error when path is file") + } +} + +func TestParseFlags_version(t *testing.T) { + t.Parallel() + + _, mode, err := parseFlags([]string{"-version"}) + if err != nil { + t.Fatal(err) + } + if mode != modeVersion { + t.Fatalf("mode %v", mode) + } +} + +func TestParseFlags_listThemes(t *testing.T) { + t.Parallel() + + _, mode, err := parseFlags([]string{"-list-themes"}) + if err != nil { + t.Fatal(err) + } + if mode != modeListThemes { + t.Fatalf("mode %v", mode) + } +} + +func TestParseFlags_run(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + cfg, mode, err := parseFlags([]string{ + "-input", in, + "-output", out, + "-theme", "neon", + "-base-url", "https://t.test", + }) + if err != nil { + t.Fatal(err) + } + if mode != modeRun { + t.Fatalf("mode %v", mode) + } + if cfg.Theme != "neon" || cfg.BaseURL != "https://t.test" { + t.Fatalf("cfg %+v", cfg) + } +} + +func TestParseFlags_randomTheme(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + cfg, _, err := parseFlags([]string{"-input", in, "-output", out, "-theme", "random"}) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, n := range generator.ListThemes() { + names[n] = true + } + if !names[cfg.Theme] { + t.Fatalf("unexpected theme %q", cfg.Theme) + } +} + +func TestParseFlags_unknownFlag(t *testing.T) { + t.Parallel() + + _, _, err := parseFlags([]string{"-not-a-real-flag"}) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, errParseFlags) { + t.Fatalf("got %v", err) + } +} + +func TestRun_pipeline(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + if err := os.WriteFile(filepath.Join(in, "a.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + InputDir: in, + OutputDir: out, + BaseURL: "https://pipe.test", + Theme: "neon", + } + if err := run(cfg); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(filepath.Join(out, "index.html")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "snonux") { + t.Fatal("expected generated html") + } +} diff --git a/internal/generator/atom/atom_test.go b/internal/generator/atom/atom_test.go new file mode 100644 index 0000000..bf705c3 --- /dev/null +++ b/internal/generator/atom/atom_test.go @@ -0,0 +1,107 @@ +package atom + +import ( + "encoding/xml" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +func TestGenerate_writesAtomXML(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cfg := &config.Config{ + OutputDir: dir, + BaseURL: "https://example.test", + } + posts := []*post.Post{ + { + ID: "p1", + Timestamp: time.Date(2026, 1, 2, 15, 4, 5, 0, time.UTC), + Content: "<p>hello</p>", + }, + } + + if err := Generate(posts, cfg); err != nil { + t.Fatalf("Generate: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "atom.xml")) + if err != nil { + t.Fatalf("read atom.xml: %v", err) + } + s := string(data) + if !strings.Contains(s, `xmlns="http://www.w3.org/2005/Atom"`) { + t.Fatalf("missing atom xmlns: %s", s) + } + if !strings.Contains(s, "https://example.test/posts/p1/") { + t.Fatalf("missing entry link: %s", s) + } + if !strings.Contains(s, "hello") || !strings.Contains(s, `type="html"`) { + t.Fatalf("missing content: %s", s) + } +} + +func TestGenerate_emptyPosts(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cfg := &config.Config{OutputDir: dir, BaseURL: "https://x.test"} + + if err := Generate(nil, cfg); err != nil { + t.Fatalf("Generate: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "atom.xml")) + if err != nil { + t.Fatalf("read: %v", err) + } + var feed struct { + Entries []struct { + Title string `xml:"title"` + } `xml:"entry"` + } + if err := xml.Unmarshal(data, &feed); err != nil { + t.Fatalf("xml: %v", err) + } + if len(feed.Entries) != 0 { + t.Fatalf("want 0 entries, got %d", len(feed.Entries)) + } +} + +func TestGenerate_limitPostsPerPage(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + cfg := &config.Config{OutputDir: dir, BaseURL: "https://x.test"} + + var posts []*post.Post + for i := 0; i < 50; i++ { + posts = append(posts, &post.Post{ + ID: "id", + Timestamp: time.Date(2026, 1, i+1, 12, 0, 0, 0, time.UTC), + Content: "c", + }) + } + + if err := Generate(posts, cfg); err != nil { + t.Fatalf("Generate: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "atom.xml")) + var feed struct { + Entries []struct{} `xml:"entry"` + } + if err := xml.Unmarshal(data, &feed); err != nil { + t.Fatalf("xml: %v", err) + } + if len(feed.Entries) != config.PostsPerPage { + t.Fatalf("want %d entries, got %d", config.PostsPerPage, len(feed.Entries)) + } +} diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 0960d87..9eafb14 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -2,9 +2,12 @@ package generator import ( "html/template" + "os" + "path/filepath" "testing" "time" + "codeberg.org/snonux/snonux/internal/config" "codeberg.org/snonux/snonux/internal/post" ) @@ -197,3 +200,68 @@ func TestBuildPageData_navLinks(t *testing.T) { }) } } + +func TestGetTheme_unknownFallsBackToNeon(t *testing.T) { + t.Parallel() + if got, want := getTheme("no-such-theme-"), getTheme("neon"); got != want { + t.Fatal("expected neon fallback") + } +} + +func TestListThemes_sortedAndComplete(t *testing.T) { + t.Parallel() + names := ListThemes() + if len(names) != len(themeRegistry) { + t.Fatalf("len=%d, want %d", len(names), len(themeRegistry)) + } + for i := 1; i < len(names); i++ { + if names[i] <= names[i-1] { + t.Fatalf("not strictly sorted: %v", names) + } + } +} + +func TestLoadAllPosts_missingPostsDir(t *testing.T) { + t.Parallel() + posts, err := loadAllPosts(t.TempDir()) + if err != nil { + t.Fatalf("err: %v", err) + } + if posts != nil { + t.Fatalf("want nil slice, got %v", posts) + } +} + +func TestRun_writesPagesAndAtom(t *testing.T) { + t.Parallel() + + out := t.TempDir() + postDir := filepath.Join(out, "posts", "a1") + if err := os.MkdirAll(postDir, 0o755); err != nil { + t.Fatal(err) + } + p := &post.Post{ + ID: "a1", + Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + PostType: post.TypeText, + Content: "<p>hello</p>", + } + if err := p.Save(postDir); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + OutputDir: out, + BaseURL: "https://example.test", + Theme: "neon", + } + if err := Run(cfg); err != nil { + t.Fatalf("Run: %v", err) + } + if _, err := os.Stat(filepath.Join(out, "index.html")); err != nil { + t.Fatalf("index.html: %v", err) + } + if _, err := os.Stat(filepath.Join(out, "atom.xml")); err != nil { + t.Fatalf("atom.xml: %v", err) + } +} diff --git a/internal/post/post_test.go b/internal/post/post_test.go index 7283afd..68d5d64 100644 --- a/internal/post/post_test.go +++ b/internal/post/post_test.go @@ -1,6 +1,8 @@ package post import ( + "os" + "path/filepath" "testing" "time" ) @@ -53,3 +55,49 @@ func TestNewID(t *testing.T) { }) } } + +func TestSave_roundTrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + p := &Post{ + ID: "2026-01-01-120000", + Timestamp: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), + PostType: TypeText, + Content: "<p>x</p>", + } + + if err := p.Save(dir); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.ID != p.ID || got.Content != p.Content || got.PostType != p.PostType { + t.Fatalf("got %+v; want %+v", got, p) + } +} + +func TestLoad_missingFile(t *testing.T) { + t.Parallel() + + _, err := Load(t.TempDir()) + if err == nil { + t.Fatal("expected error") + } +} + +func TestLoad_invalidJSON(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "post.json"), []byte("{"), 0o644); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go new file mode 100644 index 0000000..d04a0d5 --- /dev/null +++ b/internal/processor/processor_test.go @@ -0,0 +1,186 @@ +package processor + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "codeberg.org/snonux/snonux/internal/config" + "codeberg.org/snonux/snonux/internal/post" +) + +func TestRun_processesTxt(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + if err := os.WriteFile(filepath.Join(in, "note.txt"), []byte("Hello world"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x.test"} + n, err := Run(cfg) + if err != nil { + t.Fatalf("Run: %v", err) + } + if n != 1 { + t.Fatalf("processed count = %d; want 1", n) + } + + entries, err := os.ReadDir(filepath.Join(out, "posts")) + if err != nil || len(entries) != 1 { + t.Fatalf("posts dir: %v entries=%v", err, entries) + } + postDir := filepath.Join(out, "posts", entries[0].Name()) + p, err := post.Load(postDir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if p.PostType != post.TypeText { + t.Fatalf("type %v", p.PostType) + } + if _, err := os.ReadFile(filepath.Join(in, "note.txt")); !os.IsNotExist(err) { + t.Fatal("source should be removed") + } +} + +func TestRun_unsupportedExt(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + if err := os.WriteFile(filepath.Join(in, "x.bin"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRun_readInputDirFails(t *testing.T) { + t.Parallel() + + _, err := Run(&config.Config{InputDir: "/nonexistent/inbox/xyz", OutputDir: t.TempDir(), BaseURL: "https://x"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRun_png(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + pngPath := filepath.Join(in, "shot.png") + f, err := os.Create(pngPath) + if err != nil { + t.Fatal(err) + } + img := image.NewRGBA(image.Rect(0, 0, 4, 4)) + if err := png.Encode(f, img); err != nil { + f.Close() + t.Fatal(err) + } + f.Close() + + n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if n != 1 { + t.Fatalf("n=%d", n) + } +} + +func TestRun_mp3(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + if err := os.WriteFile(filepath.Join(in, "clip.mp3"), []byte("fake-mp3-bytes"), 0o644); err != nil { + t.Fatal(err) + } + + n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if n != 1 { + t.Fatalf("n=%d", n) + } +} + +func TestRun_markdown(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + if err := os.WriteFile(filepath.Join(in, "x.md"), []byte("# Hi\n\n**bold**"), 0o644); err != nil { + t.Fatal(err) + } + + n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if n != 1 { + t.Fatalf("n=%d", n) + } + + entries, _ := os.ReadDir(filepath.Join(out, "posts")) + p, err := post.Load(filepath.Join(out, "posts", entries[0].Name())) + if err != nil { + t.Fatal(err) + } + if p.PostType != post.TypeMarkdown { + t.Fatalf("got %v", p.PostType) + } +} + +func TestRun_markdownWithLocalImage(t *testing.T) { + t.Parallel() + + in := t.TempDir() + out := t.TempDir() + pngPath := filepath.Join(in, "embed.png") + f, err := os.Create(pngPath) + if err != nil { + t.Fatal(err) + } + if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil { + f.Close() + t.Fatal(err) + } + f.Close() + + md := ` +text` + if err := os.WriteFile(filepath.Join(in, "post.md"), []byte(md), 0o644); err != nil { + t.Fatal(err) + } + + n, err := Run(&config.Config{InputDir: in, OutputDir: out, BaseURL: "https://x"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + if n != 1 { + t.Fatalf("n=%d", n) + } + + entries, _ := os.ReadDir(filepath.Join(out, "posts")) + pdir := filepath.Join(out, "posts", entries[0].Name()) + p, err := post.Load(pdir) + if err != nil { + t.Fatal(err) + } + if len(p.Assets) < 1 { + t.Fatalf("want assets, got %+v", p.Assets) + } + if _, err := os.Stat(filepath.Join(pdir, "embed.png")); err != nil { + t.Fatal(err) + } +} diff --git a/internal/version.go b/internal/version/version.go index a0e0dea..fb3eae6 100644 --- a/internal/version.go +++ b/internal/version/version.go @@ -2,4 +2,4 @@ package version // Version is the application version (semantic versioning). -const Version = "0.1.2" +const Version = "0.1.3" |
