Skip to content

Commit 049d273

Browse files
committed
add more tests
1 parent 34120d7 commit 049d273

10 files changed

Lines changed: 789 additions & 1030 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.claude/
2-
build/
2+
build/
3+
coverage.out

CLAUDE.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build & Test
6+
7+
```bash
8+
make build # Build binary to ./build/dotular
9+
go test ./... # Run all tests
10+
go test -race ./... # Run tests with race detector (CI default)
11+
go test ./internal/config/ # Run tests for a single package
12+
go test -run TestLoad ./internal/config/ # Run a single test
13+
go vet ./... # Lint (only linter used)
14+
```
15+
16+
CI enforces 80% code coverage minimum (`go test -race -coverprofile=coverage.out -covermode=atomic ./...`).
17+
18+
## Architecture
19+
20+
Go CLI dotfile manager using Cobra. Module path: `github.com/atomikpanda/dotular`, requires Go 1.22+.
21+
22+
**Config-driven**: A `dotular.yaml` file defines modules, each containing items (package installs, file syncs, scripts, settings, binaries, directory trees, inline commands). The config supports both a mapping format (with `modules:` key) and a legacy bare-sequence format.
23+
24+
**Key flow**: `cmd/dotular/main.go` parses CLI flags and loads config → `internal/registry/` resolves any remote module references → `internal/runner/runner.go` orchestrates applying modules with hooks/snapshots/audit → `internal/actions/` executes each item type.
25+
26+
**File/directory items**: The repo acts as the managed store. Each module's files live in a directory named after the module (e.g., `nvim/init.lua`). The runner's `buildAction` prepends the module name to the item's filename via `sourcePrefix`. `PlatformMap` handles per-OS destination paths.
27+
28+
**Action types** (in `internal/actions/`): `package`, `script`, `file`, `directory`, `binary`, `run`, `setting` — each implements the `Action` interface (`Describe()`, `Run()`). Some also implement `Idempotent` (`IsApplied()`).
29+
30+
**Cross-cutting concerns**: `internal/snapshot/` provides atomic rollback per module. `internal/audit/` logs all actions. `internal/tags/` filters modules by machine tags. `internal/ageutil/` handles age encryption for sensitive files.
31+
32+
## YAML Config Schema
33+
34+
Items are polymorphic — the type is determined by which primary field is set (`package`, `script`, `file`, `directory`, `binary`, `run`, `setting`). Shared fields: `via`, `skip_if`, `verify`, `hooks`.
35+
36+
`PlatformMap` accepts either a scalar (all platforms) or a `macos`/`windows`/`linux` mapping. It has custom YAML marshal/unmarshal methods.
37+
38+
## Dependencies
39+
40+
- `github.com/spf13/cobra` — CLI framework
41+
- `gopkg.in/yaml.v3` — YAML parsing
42+
- `filippo.io/age` — age encryption

cmd/dotular/main_test.go

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66
"path/filepath"
77
"testing"
8+
9+
"github.com/atomikpanda/dotular/internal/config"
810
)
911

1012
func writeTestConfig(t *testing.T, content string) string {
@@ -32,7 +34,7 @@ func TestBuildRoot(t *testing.T) {
3234
names[cmd.Name()] = true
3335
}
3436

35-
expected := []string{"apply", "push", "pull", "sync", "list", "status", "platform", "verify", "encrypt", "decrypt", "tag", "log", "registry"}
37+
expected := []string{"add", "apply", "push", "pull", "sync", "list", "status", "platform", "verify", "encrypt", "decrypt", "tag", "log", "registry"}
3638
for _, name := range expected {
3739
if !names[name] {
3840
t.Errorf("missing subcommand %q", name)
@@ -509,3 +511,258 @@ func TestRegistryUpdateCmdExecute(t *testing.T) {
509511
t.Fatal(err)
510512
}
511513
}
514+
515+
// --- add command tests -------------------------------------------------------
516+
517+
func TestAddCmdDef(t *testing.T) {
518+
cmd := addCmd()
519+
if cmd.Use != "add <module> <path>" {
520+
t.Errorf("Use = %q", cmd.Use)
521+
}
522+
}
523+
524+
func TestAddCmdFile(t *testing.T) {
525+
dir := t.TempDir()
526+
cfgPath := filepath.Join(dir, "dotular.yaml")
527+
os.WriteFile(cfgPath, []byte("modules: []\n"), 0o644)
528+
529+
// Create a source file.
530+
srcFile := filepath.Join(dir, "myfile.txt")
531+
os.WriteFile(srcFile, []byte("hello"), 0o644)
532+
533+
root := buildRoot()
534+
root.SetArgs([]string{"add", "--config", cfgPath, "mymod", srcFile})
535+
if err := root.Execute(); err != nil {
536+
t.Fatal(err)
537+
}
538+
539+
// Verify the file was copied into the module store.
540+
stored := filepath.Join(dir, "mymod", "myfile.txt")
541+
data, err := os.ReadFile(stored)
542+
if err != nil {
543+
t.Fatalf("stored file not found: %v", err)
544+
}
545+
if string(data) != "hello" {
546+
t.Errorf("stored content = %q", string(data))
547+
}
548+
549+
// Verify the config was updated.
550+
cfg, err := loadConfigFrom(cfgPath)
551+
if err != nil {
552+
t.Fatal(err)
553+
}
554+
mod := cfg.Module("mymod")
555+
if mod == nil {
556+
t.Fatal("module 'mymod' not found in config")
557+
}
558+
if len(mod.Items) != 1 {
559+
t.Fatalf("expected 1 item, got %d", len(mod.Items))
560+
}
561+
if mod.Items[0].File != "myfile.txt" {
562+
t.Errorf("item file = %q", mod.Items[0].File)
563+
}
564+
}
565+
566+
func TestAddCmdDirectory(t *testing.T) {
567+
dir := t.TempDir()
568+
cfgPath := filepath.Join(dir, "dotular.yaml")
569+
os.WriteFile(cfgPath, []byte("modules: []\n"), 0o644)
570+
571+
// Create a source directory.
572+
srcDir := filepath.Join(dir, "mydir")
573+
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o755)
574+
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("aaa"), 0o644)
575+
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bbb"), 0o644)
576+
577+
root := buildRoot()
578+
root.SetArgs([]string{"add", "--config", cfgPath, "mymod", srcDir})
579+
if err := root.Execute(); err != nil {
580+
t.Fatal(err)
581+
}
582+
583+
// Verify the directory was copied.
584+
data, err := os.ReadFile(filepath.Join(dir, "mymod", "mydir", "sub", "b.txt"))
585+
if err != nil {
586+
t.Fatalf("stored file not found: %v", err)
587+
}
588+
if string(data) != "bbb" {
589+
t.Errorf("stored content = %q", string(data))
590+
}
591+
592+
// Verify the config.
593+
cfg, err := loadConfigFrom(cfgPath)
594+
if err != nil {
595+
t.Fatal(err)
596+
}
597+
mod := cfg.Module("mymod")
598+
if mod == nil {
599+
t.Fatal("module 'mymod' not found")
600+
}
601+
if mod.Items[0].Directory != "mydir" {
602+
t.Errorf("item directory = %q", mod.Items[0].Directory)
603+
}
604+
}
605+
606+
func TestAddCmdToExistingModule(t *testing.T) {
607+
dir := t.TempDir()
608+
cfgPath := filepath.Join(dir, "dotular.yaml")
609+
os.WriteFile(cfgPath, []byte(`
610+
modules:
611+
- name: existing
612+
items:
613+
- package: git
614+
via: brew
615+
`), 0o644)
616+
617+
srcFile := filepath.Join(dir, "extra.txt")
618+
os.WriteFile(srcFile, []byte("extra"), 0o644)
619+
620+
root := buildRoot()
621+
root.SetArgs([]string{"add", "--config", cfgPath, "existing", srcFile})
622+
if err := root.Execute(); err != nil {
623+
t.Fatal(err)
624+
}
625+
626+
cfg, err := loadConfigFrom(cfgPath)
627+
if err != nil {
628+
t.Fatal(err)
629+
}
630+
mod := cfg.Module("existing")
631+
if mod == nil {
632+
t.Fatal("module 'existing' not found")
633+
}
634+
if len(mod.Items) != 2 {
635+
t.Fatalf("expected 2 items, got %d", len(mod.Items))
636+
}
637+
}
638+
639+
func TestAddCmdWithLink(t *testing.T) {
640+
dir := t.TempDir()
641+
cfgPath := filepath.Join(dir, "dotular.yaml")
642+
os.WriteFile(cfgPath, []byte("modules: []\n"), 0o644)
643+
644+
srcFile := filepath.Join(dir, "linkme.txt")
645+
os.WriteFile(srcFile, []byte("data"), 0o644)
646+
647+
root := buildRoot()
648+
root.SetArgs([]string{"add", "--config", cfgPath, "--link", "linkmod", srcFile})
649+
if err := root.Execute(); err != nil {
650+
t.Fatal(err)
651+
}
652+
653+
cfg, err := loadConfigFrom(cfgPath)
654+
if err != nil {
655+
t.Fatal(err)
656+
}
657+
mod := cfg.Module("linkmod")
658+
if mod == nil {
659+
t.Fatal("module not found")
660+
}
661+
if !mod.Items[0].Link {
662+
t.Error("expected link=true")
663+
}
664+
}
665+
666+
func TestAddCmdWithDirection(t *testing.T) {
667+
dir := t.TempDir()
668+
cfgPath := filepath.Join(dir, "dotular.yaml")
669+
os.WriteFile(cfgPath, []byte("modules: []\n"), 0o644)
670+
671+
srcFile := filepath.Join(dir, "syncme.txt")
672+
os.WriteFile(srcFile, []byte("data"), 0o644)
673+
674+
root := buildRoot()
675+
root.SetArgs([]string{"add", "--config", cfgPath, "--direction", "sync", "syncmod", srcFile})
676+
if err := root.Execute(); err != nil {
677+
t.Fatal(err)
678+
}
679+
680+
cfg, err := loadConfigFrom(cfgPath)
681+
if err != nil {
682+
t.Fatal(err)
683+
}
684+
mod := cfg.Module("syncmod")
685+
if mod == nil {
686+
t.Fatal("module not found")
687+
}
688+
if mod.Items[0].Direction != "sync" {
689+
t.Errorf("direction = %q, want sync", mod.Items[0].Direction)
690+
}
691+
}
692+
693+
func TestAddCmdMissingPath(t *testing.T) {
694+
dir := t.TempDir()
695+
cfgPath := filepath.Join(dir, "dotular.yaml")
696+
os.WriteFile(cfgPath, []byte("modules: []\n"), 0o644)
697+
698+
root := buildRoot()
699+
root.SetArgs([]string{"add", "--config", cfgPath, "mymod", "/nonexistent/path"})
700+
if err := root.Execute(); err == nil {
701+
t.Error("expected error for nonexistent source path")
702+
}
703+
}
704+
705+
func TestAddCmdRequiresArgs(t *testing.T) {
706+
root := buildRoot()
707+
root.SetArgs([]string{"add"})
708+
if err := root.Execute(); err == nil {
709+
t.Error("expected error for missing args")
710+
}
711+
}
712+
713+
func TestCopyFileSimple(t *testing.T) {
714+
dir := t.TempDir()
715+
src := filepath.Join(dir, "src.txt")
716+
dst := filepath.Join(dir, "dst.txt")
717+
os.WriteFile(src, []byte("content"), 0o644)
718+
719+
if err := copyFileSimple(src, dst); err != nil {
720+
t.Fatal(err)
721+
}
722+
723+
data, _ := os.ReadFile(dst)
724+
if string(data) != "content" {
725+
t.Errorf("copied = %q", string(data))
726+
}
727+
728+
// Verify permissions are preserved.
729+
srcInfo, _ := os.Stat(src)
730+
dstInfo, _ := os.Stat(dst)
731+
if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() {
732+
t.Errorf("permissions: src=%o, dst=%o", srcInfo.Mode().Perm(), dstInfo.Mode().Perm())
733+
}
734+
}
735+
736+
func TestCopyDirRecursive(t *testing.T) {
737+
dir := t.TempDir()
738+
src := filepath.Join(dir, "src")
739+
dst := filepath.Join(dir, "dst")
740+
os.MkdirAll(filepath.Join(src, "sub"), 0o755)
741+
os.WriteFile(filepath.Join(src, "a.txt"), []byte("aaa"), 0o644)
742+
os.WriteFile(filepath.Join(src, "sub", "b.txt"), []byte("bbb"), 0o644)
743+
744+
if err := copyDirRecursive(src, dst); err != nil {
745+
t.Fatal(err)
746+
}
747+
748+
data, err := os.ReadFile(filepath.Join(dst, "a.txt"))
749+
if err != nil {
750+
t.Fatal(err)
751+
}
752+
if string(data) != "aaa" {
753+
t.Errorf("a.txt = %q", string(data))
754+
}
755+
756+
data, err = os.ReadFile(filepath.Join(dst, "sub", "b.txt"))
757+
if err != nil {
758+
t.Fatal(err)
759+
}
760+
if string(data) != "bbb" {
761+
t.Errorf("sub/b.txt = %q", string(data))
762+
}
763+
}
764+
765+
// loadConfigFrom is a helper that loads config from a specific path.
766+
func loadConfigFrom(path string) (config.Config, error) {
767+
return config.Load(path)
768+
}

0 commit comments

Comments
 (0)