ajhahn.de
← eeco
Go 419 lines
package config

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestInit_CreatesWorkspaceTree(t *testing.T) {
	root := newRepo(t)
	write(t, root, "go.mod", "module x\n")

	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	rep, err := Init(cfg)
	if err != nil {
		t.Fatal(err)
	}

	for _, sub := range workspaceSubdirs {
		info, err := os.Stat(filepath.Join(root, "tester", DefaultWorkspace, sub))
		if err != nil {
			t.Errorf("subdir %s missing: %v", sub, err)
			continue
		}
		if !info.IsDir() {
			t.Errorf("subdir %s is not a directory", sub)
		}
	}
	if !rep.WroteReadme {
		t.Error("expected WroteReadme=true on first init")
	}
	if !rep.GitignoreChanged {
		t.Error("expected GitignoreChanged=true on first init")
	}
	if rep.AlreadyInit {
		t.Error("expected AlreadyInit=false on first init")
	}
	if len(rep.CreatedDirs) != len(workspaceSubdirs) {
		t.Errorf("CreatedDirs = %v, want %d entries", rep.CreatedDirs, len(workspaceSubdirs))
	}
}

func TestInit_Idempotent(t *testing.T) {
	root := newRepo(t)
	write(t, root, "go.mod", "module x\n")

	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}

	rep, err := Init(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if !rep.AlreadyInit {
		t.Error("expected AlreadyInit=true on re-init")
	}
	if rep.WroteReadme {
		t.Error("expected WroteReadme=false on re-init")
	}
	if rep.GitignoreChanged {
		t.Error("expected GitignoreChanged=false on re-init")
	}
	if len(rep.CreatedDirs) != 0 {
		t.Errorf("CreatedDirs on re-init = %v, want empty", rep.CreatedDirs)
	}
}

func TestInit_AppendsToExistingGitignore(t *testing.T) {
	root := newRepo(t)
	write(t, root, ".gitignore", "node_modules/\n*.log\n")
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}
	b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
	if err != nil {
		t.Fatal(err)
	}
	s := string(b)
	// Init now ignores the per-user dir (cfg.Username = "tester"), not the
	// workspace leaf, so the appended line is /tester/.
	wantLines := []string{"node_modules/", "*.log", "/tester/"}
	for _, l := range wantLines {
		if !strings.Contains(s, l+"\n") {
			t.Errorf(".gitignore missing line %q. got:\n%s", l, s)
		}
	}
}

func TestInit_GitignoreNoTrailingNewline(t *testing.T) {
	root := newRepo(t)
	write(t, root, ".gitignore", "node_modules/")
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}
	b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
	if err != nil {
		t.Fatal(err)
	}
	want := "node_modules/\n/tester/\n"
	if string(b) != want {
		t.Errorf(".gitignore =\n%q\nwant\n%q", string(b), want)
	}
}

func TestInit_CreatesGitignoreWhenMissing(t *testing.T) {
	root := newRepo(t)
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}
	b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
	if err != nil {
		t.Fatal(err)
	}
	if string(b) != "/tester/\n" {
		t.Errorf("created .gitignore =\n%q\nwant exactly /tester/\\n", string(b))
	}
}

func TestInit_RecognisesExistingIgnoreVariants(t *testing.T) {
	// Init ignores the per-user dir (cfg.Username = "tester"), so an
	// existing equivalent line is one of the tester variants.
	cases := []string{
		"tester",
		"tester/",
		"/tester",
		"/tester/",
	}
	for _, variant := range cases {
		t.Run(variant, func(t *testing.T) {
			root := newRepo(t)
			write(t, root, ".gitignore", "# preamble\n"+variant+"\n")
			cfg, err := Load(root, "")
			if err != nil {
				t.Fatal(err)
			}
			rep, err := Init(cfg)
			if err != nil {
				t.Fatal(err)
			}
			if rep.GitignoreChanged {
				t.Errorf("variant %q caused gitignore append", variant)
			}
			// gitignore content must be unchanged
			b, _ := os.ReadFile(filepath.Join(root, ".gitignore"))
			if string(b) != "# preamble\n"+variant+"\n" {
				t.Errorf("gitignore mutated:\n%s", string(b))
			}
		})
	}
}

func TestInit_HonoursCustomWorkspaceName(t *testing.T) {
	root := newRepo(t)
	cfg, err := Load(root, ".workshop")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}
	if _, err := os.Stat(filepath.Join(root, "tester", ".workshop", "memory")); err != nil {
		t.Errorf("custom workspace not created: %v", err)
	}
	b, _ := os.ReadFile(filepath.Join(root, ".gitignore"))
	// Init ignores the per-user dir, not the workspace leaf, so even a
	// custom workspace name yields /tester/.
	if !strings.Contains(string(b), "/tester/\n") {
		t.Errorf("gitignore missing /tester/: %s", string(b))
	}
}

func TestInit_ErrorsWhenWorkspacePathIsAFile(t *testing.T) {
	root := newRepo(t)
	write(t, root, filepath.Join("tester", DefaultWorkspace), "i am a file, not a dir")
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err == nil {
		t.Fatal("expected Init to error when workspace path is a file")
	}
}

func TestIsInitialized(t *testing.T) {
	root := newRepo(t)
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if IsInitialized(cfg) {
		t.Error("expected IsInitialized=false before Init")
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}
	if !IsInitialized(cfg) {
		t.Error("expected IsInitialized=true after Init")
	}
}

func TestInit_PreservesExistingReadme(t *testing.T) {
	root := newRepo(t)
	wsDir := filepath.Join(root, "tester", DefaultWorkspace)
	if err := os.MkdirAll(wsDir, 0o755); err != nil {
		t.Fatal(err)
	}
	custom := "user wrote this README themselves"
	write(t, wsDir, "README.md", custom)
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	rep, err := Init(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if rep.WroteReadme {
		t.Error("expected WroteReadme=false when README already exists")
	}
	b, _ := os.ReadFile(filepath.Join(wsDir, "README.md"))
	if string(b) != custom {
		t.Errorf("README overwritten:\n%s", string(b))
	}
}

func TestInit_ScaffoldsKnowledgeDirs(t *testing.T) {
	root := newRepo(t)
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	// A safe set plus one unsafe component that must be skipped, never
	// written outside UserDir.
	cfg.KnowledgeDirs = []string{"frontend", "backend", "../evil", "docs"}

	rep, err := Init(cfg)
	if err != nil {
		t.Fatal(err)
	}
	for _, d := range []string{"frontend", "backend", "docs"} {
		info, err := os.Stat(filepath.Join(root, "tester", d))
		if err != nil || !info.IsDir() {
			t.Errorf("knowledge dir %s not created under UserDir: %v", d, err)
		}
	}
	if _, err := os.Stat(filepath.Join(root, "evil")); err == nil {
		t.Error("unsafe knowledge dir \"../evil\" escaped UserDir")
	}
	if got := strings.Join(rep.CreatedKnowledgeDirs, ","); got != "frontend,backend,docs" {
		t.Errorf("CreatedKnowledgeDirs = %q, want frontend,backend,docs", got)
	}

	// Idempotent: a second Init creates nothing new.
	rep2, err := Init(cfg)
	if err != nil {
		t.Fatal(err)
	}
	if len(rep2.CreatedKnowledgeDirs) != 0 {
		t.Errorf("re-init CreatedKnowledgeDirs = %v, want empty", rep2.CreatedKnowledgeDirs)
	}
}

// --- H1.2: branch/edge coverage deepening (test-only) ---

// TestSafeDirComponent covers the empty/"."/".." reject (init.go:136-138)
// and the not-clean reject (139-141) arms by calling the validator directly.
func TestSafeDirComponent(t *testing.T) {
	cases := []struct {
		name string
		want bool
	}{
		{"", false},
		{".", false},
		{"..", false},
		{"a/b", false},
		{"./x", false},
		{"/abs", false},
		{`a\b`, false},
		{"frontend", true},
		{"a.b", true},
		{"a-b_c", true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			if got := safeDirComponent(tc.name); got != tc.want {
				t.Errorf("safeDirComponent(%q) = %v, want %v", tc.name, got, tc.want)
			}
		})
	}
}

// TestInit_NilConfig covers the nil guard (init.go:45-47).
func TestInit_NilConfig(t *testing.T) {
	if _, err := Init(nil); err == nil {
		t.Fatal("Init(nil) = nil error, want error")
	}
}

// TestIsInitialized_NilConfig covers the nil guard (init.go:119-121).
func TestIsInitialized_NilConfig(t *testing.T) {
	if IsInitialized(nil) {
		t.Fatal("IsInitialized(nil) = true, want false")
	}
}

// TestIsInitialized_IncompleteWorkspace covers the false arm of the
// subdir-missing check (init.go:124): a workspace missing one canonical
// subdir is not initialised.
func TestIsInitialized_IncompleteWorkspace(t *testing.T) {
	root := newRepo(t)
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Init(cfg); err != nil {
		t.Fatal(err)
	}
	if !IsInitialized(cfg) {
		t.Fatal("expected IsInitialized=true after Init")
	}
	if err := os.RemoveAll(filepath.Join(cfg.Workspace, "memory")); err != nil {
		t.Fatal(err)
	}
	if IsInitialized(cfg) {
		t.Error("expected IsInitialized=false with a subdir removed")
	}
}

// TestInit_ErrorsWhenSubdirPathIsAFile covers the loop error propagation
// (init.go:64-66) and the ensureDirCreated non-dir arm (165-167): a file
// occupying a canonical subdir path surfaces a clear error.
func TestInit_ErrorsWhenSubdirPathIsAFile(t *testing.T) {
	root := newRepo(t)
	wsDir := filepath.Join(root, "tester", DefaultWorkspace)
	// "engine" is created first and succeeds; "memory" is the file in the way.
	write(t, wsDir, "memory", "i am a file")
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	_, err = Init(cfg)
	if err == nil {
		t.Fatal("expected Init to error when a subdir path is a file")
	}
	if !strings.Contains(err.Error(), "exists and is not a directory") {
		t.Errorf("error = %q, want it to contain %q", err.Error(), "exists and is not a directory")
	}
}

// TestInit_ErrorsWhenGitignoreIsADirectory covers the ensureIgnored
// ReadFile non-NotExist arm (init.go:187-189) and the Init wrap (101-103):
// the subdirs are created before .gitignore is touched, so a directory at
// the .gitignore path fails the ReadFile cleanly.
func TestInit_ErrorsWhenGitignoreIsADirectory(t *testing.T) {
	root := newRepo(t)
	if err := os.Mkdir(filepath.Join(root, ".gitignore"), 0o755); err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(root, "")
	if err != nil {
		t.Fatal(err)
	}
	_, err = Init(cfg)
	if err == nil {
		t.Fatal("expected Init to error when .gitignore is a directory")
	}
	if !strings.Contains(err.Error(), "update .gitignore") {
		t.Errorf("error = %q, want it to contain %q", err.Error(), "update .gitignore")
	}
}

// TestInit_EmptyUsernameIgnoresWorkspaceName covers the empty-username
// fallback (init.go:97-99), only reachable with Username=="" (which Load
// never produces). Built directly, not via a seam. UserDir is empty so the
// knowledge loop is skipped; ensureIgnored receives WorkspaceName.
func TestInit_EmptyUsernameIgnoresWorkspaceName(t *testing.T) {
	root := newRepo(t)
	cfg := &Config{
		RepoRoot:      root,
		Username:      "",
		WorkspaceName: DefaultWorkspace,
		Workspace:     filepath.Join(root, DefaultWorkspace),
		Profile:       ProfileGeneric,
	}
	rep, err := Init(cfg)
	if err != nil {
		t.Fatalf("Init: %v", err)
	}
	b, err := os.ReadFile(rep.GitignorePath)
	if err != nil {
		t.Fatal(err)
	}
	want := "/" + DefaultWorkspace + "/"
	if !strings.Contains(string(b), want) {
		t.Errorf(".gitignore = %q, want it to contain %q", string(b), want)
	}
}