diff --git a/internal/cli/render.go b/internal/cli/render.go new file mode 100644 index 0000000..53c405c --- /dev/null +++ b/internal/cli/render.go @@ -0,0 +1,127 @@ +package cli + +import ( + "fmt" + "io" + "strconv" + + "github.com/alyraffauf/cattery/internal/application/add" + "github.com/alyraffauf/cattery/internal/application/apply" + "github.com/alyraffauf/cattery/internal/application/inspect" + "github.com/alyraffauf/cattery/internal/application/validate" +) + +// renderAdd writes one line per item record and the summary line of one +// add result (PLAN.md Section 11.6). +func renderAdd(writer io.Writer, result add.Result) error { + for _, item := range result.Items { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", + displayPath(item.Target), item.Status, displayPath(item.Source)); err != nil { + return err + } + } + _, err := fmt.Fprintf(writer, "summary planned=%d completed=%d partial=%d\n", + result.Summary.Planned, result.Summary.Completed, result.Summary.Partial) + return err +} + +// renderApply writes one line per item record and the summary line of one +// apply result (PLAN.md Section 11.5). +func renderApply(writer io.Writer, result apply.Result) error { + for _, item := range result.Items { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", + displayPath(item.TargetPath), item.Status, item.Kind); err != nil { + return err + } + } + _, err := fmt.Fprintf(writer, "summary planned=%d completed=%d partial=%d\n", + result.Summary.Planned, result.Summary.Completed, result.Summary.Partial) + return err +} + +// renderValidate writes the two deterministic platform count lines of one +// validate result (PLAN.md Section 11.2). +func renderValidate(writer io.Writer, result validate.Result) error { + for _, record := range result.Platforms { + if _, err := fmt.Fprintf(writer, "%s files=%d secrets=%d aliases=%d groups=%d\n", + record.Platform, record.Files, record.Secrets, record.Aliases, record.Groups); err != nil { + return err + } + } + return nil +} + +// renderStatus writes one line per pending record and the summary line of +// one status result (PLAN.md Sections 11.3 and 11.9). +func renderStatus(writer io.Writer, result inspect.StatusResult) error { + for _, record := range result.Records() { + if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", + displayPath(record.TargetPath()), record.Kind(), record.Action()); err != nil { + return err + } + } + _, err := fmt.Fprintf(writer, "summary files=%d aliases=%d retired=%d converged=%t\n", + result.Files(), result.Aliases(), result.Retired(), result.Converged()) + return err +} + +// renderDiff writes one line per tagged safe record plus the summary line +// of one diff result (PLAN.md Section 11.4). Secret records render the +// marker only, with zero content, size, or hash fields. +func renderDiff(writer io.Writer, result inspect.DiffResult) error { + for _, record := range result.Records() { + if err := renderDiffRecord(writer, record); err != nil { + return err + } + } + _, err := fmt.Fprintf(writer, "summary files=%d aliases=%d retired=%d converged=%t\n", + result.Files(), result.Aliases(), result.Retired(), result.Converged()) + return err +} + +func renderDiffRecord(writer io.Writer, record inspect.DiffRecord) error { + path := "$HOME/" + displayPath(record.TargetPath()) + switch inspect.DiffTagName(record) { + case "text": + return renderTextDiff(writer, path, record) + case "binary": + _, err := fmt.Fprintf(writer, "%s %s binary size=%d/%d\n", + path, record.Kind(), record.SourceSize(), record.TargetSize()) + return err + case "secret": + _, err := fmt.Fprintf(writer, "%s %s secret\n", path, record.Kind()) + return err + } + _, err := fmt.Fprintf(writer, "%s %s %s\n", path, record.Kind(), record.Action()) + return err +} + +func renderTextDiff(writer io.Writer, path string, record inspect.DiffRecord) error { + if _, err := fmt.Fprintf(writer, "%s %s %s\n", path, record.Kind(), record.Action()); err != nil { + return err + } + if _, err := fmt.Fprintf(writer, "%s\n", record.SourceLabel()); err != nil { + return err + } + _, err := fmt.Fprintf(writer, "%s", record.Lines()) + return err +} + +// displayPath escapes control characters and ambiguous whitespace with the +// stable Go-style quoted representation so a filename can never inject +// terminal lines. +func displayPath(path string) string { + if needsQuoting(path) { + return strconv.Quote(path) + } + return path +} + +func needsQuoting(path string) bool { + for _, character := range path { + if character < 0x20 || character == 0x7f || character == ' ' { + return true + } + } + return false +} diff --git a/internal/cli/render_add.go b/internal/cli/render_add.go deleted file mode 100644 index 65992a0..0000000 --- a/internal/cli/render_add.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "fmt" - "io" - - "github.com/alyraffauf/cattery/internal/application/add" -) - -// renderAdd writes one line per item record and the summary line of one -// add result (PLAN.md Section 11.6). -func renderAdd(writer io.Writer, result add.Result) error { - for _, item := range result.Items { - if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", - displayPath(item.Target), item.Status, displayPath(item.Source)); err != nil { - return err - } - } - _, err := fmt.Fprintf(writer, "summary planned=%d completed=%d partial=%d\n", - result.Summary.Planned, result.Summary.Completed, result.Summary.Partial) - return err -} diff --git a/internal/cli/render_apply.go b/internal/cli/render_apply.go deleted file mode 100644 index 75438e4..0000000 --- a/internal/cli/render_apply.go +++ /dev/null @@ -1,22 +0,0 @@ -package cli - -import ( - "fmt" - "io" - - "github.com/alyraffauf/cattery/internal/application/apply" -) - -// renderApply writes one line per item record and the summary line of one -// apply result (PLAN.md Section 11.5). -func renderApply(writer io.Writer, result apply.Result) error { - for _, item := range result.Items { - if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", - displayPath(item.TargetPath), item.Status, item.Kind); err != nil { - return err - } - } - _, err := fmt.Fprintf(writer, "summary planned=%d completed=%d partial=%d\n", - result.Summary.Planned, result.Summary.Completed, result.Summary.Partial) - return err -} diff --git a/internal/cli/render_diff.go b/internal/cli/render_diff.go deleted file mode 100644 index a264c14..0000000 --- a/internal/cli/render_diff.go +++ /dev/null @@ -1,53 +0,0 @@ -package cli - -import ( - "fmt" - "io" - - "github.com/alyraffauf/cattery/internal/application/inspect" -) - -// renderDiff writes one line per tagged safe record plus the summary line -// of one diff result (PLAN.md Section 11.4). Secret records render the -// marker only, with zero content, size, or hash fields. -func renderDiff(writer io.Writer, result inspect.DiffResult) error { - for _, record := range result.Records() { - if err := renderDiffRecord(writer, record); err != nil { - return err - } - } - _, err := fmt.Fprintf(writer, "summary files=%d aliases=%d retired=%d converged=%t\n", - result.Files(), result.Aliases(), result.Retired(), result.Converged()) - return err -} - -// renderDiffRecord writes one safe record line and its payload. -func renderDiffRecord(writer io.Writer, record inspect.DiffRecord) error { - path := "$HOME/" + displayPath(record.TargetPath()) - switch inspect.DiffTagName(record) { - case "text": - return renderTextDiff(writer, path, record) - case "binary": - _, err := fmt.Fprintf(writer, "%s %s binary size=%d/%d\n", - path, record.Kind(), record.SourceSize(), record.TargetSize()) - return err - case "secret": - _, err := fmt.Fprintf(writer, "%s %s secret\n", path, record.Kind()) - return err - } - _, err := fmt.Fprintf(writer, "%s %s %s\n", path, record.Kind(), record.Action()) - return err -} - -// renderTextDiff writes the record line, the label line, and the diff -// lines of one printable text difference. -func renderTextDiff(writer io.Writer, path string, record inspect.DiffRecord) error { - if _, err := fmt.Fprintf(writer, "%s %s %s\n", path, record.Kind(), record.Action()); err != nil { - return err - } - if _, err := fmt.Fprintf(writer, "%s\n", record.SourceLabel()); err != nil { - return err - } - _, err := fmt.Fprintf(writer, "%s", record.Lines()) - return err -} diff --git a/internal/cli/render_status.go b/internal/cli/render_status.go deleted file mode 100644 index 6d0541c..0000000 --- a/internal/cli/render_status.go +++ /dev/null @@ -1,44 +0,0 @@ -package cli - -import ( - "fmt" - "io" - "strconv" - - "github.com/alyraffauf/cattery/internal/application/inspect" -) - -// renderStatus writes one line per pending record and the summary line of -// one status result (PLAN.md Sections 11.3 and 11.9). -func renderStatus(writer io.Writer, result inspect.StatusResult) error { - for _, record := range result.Records() { - if _, err := fmt.Fprintf(writer, "$HOME/%s %s %s\n", - displayPath(record.TargetPath()), record.Kind(), record.Action()); err != nil { - return err - } - } - _, err := fmt.Fprintf(writer, "summary files=%d aliases=%d retired=%d converged=%t\n", - result.Files(), result.Aliases(), result.Retired(), result.Converged()) - return err -} - -// displayPath escapes control characters and ambiguous whitespace with the -// stable Go-style quoted representation so a filename can never inject -// terminal lines. -func displayPath(path string) string { - if needsQuoting(path) { - return strconv.Quote(path) - } - return path -} - -// needsQuoting reports whether a path carries control characters or -// ambiguous whitespace. -func needsQuoting(path string) bool { - for _, character := range path { - if character < 0x20 || character == 0x7f || character == ' ' { - return true - } - } - return false -} diff --git a/internal/cli/render_validate.go b/internal/cli/render_validate.go deleted file mode 100644 index 9e8aae6..0000000 --- a/internal/cli/render_validate.go +++ /dev/null @@ -1,20 +0,0 @@ -package cli - -import ( - "fmt" - "io" - - "github.com/alyraffauf/cattery/internal/application/validate" -) - -// renderValidate writes the two deterministic platform count lines of one -// validate result (PLAN.md Section 11.2). -func renderValidate(writer io.Writer, result validate.Result) error { - for _, record := range result.Platforms { - if _, err := fmt.Fprintf(writer, "%s files=%d secrets=%d aliases=%d groups=%d\n", - record.Platform, record.Files, record.Secrets, record.Aliases, record.Groups); err != nil { - return err - } - } - return nil -} diff --git a/internal/deployment/sort.go b/internal/deployment/sort.go index a06a6bb..b61f76d 100644 --- a/internal/deployment/sort.go +++ b/internal/deployment/sort.go @@ -1,19 +1,9 @@ package deployment -import ( - "slices" - "sort" -) +import "sort" -// LessScope reports whether scope a precedes b in bytewise group order. The -// root scope (empty Group) sorts first because the empty string is the -// bytewise minimum. -func LessScope(a, b Scope) bool { - return a.Group < b.Group -} - -// LessManagedFile reports whether file a precedes b in a bytewise total order. -func LessManagedFile(a, b ManagedFile) bool { +// lessManagedFile reports whether file a precedes b in a bytewise total order. +func lessManagedFile(a, b ManagedFile) bool { if a.TargetRelativePath != b.TargetRelativePath { return a.TargetRelativePath < b.TargetRelativePath } @@ -35,16 +25,16 @@ func LessManagedFile(a, b ManagedFile) bool { return a.SourceExecutableBits < b.SourceExecutableBits } -// LessAlias reports whether alias a precedes b in bytewise AliasRelativePath +// lessAlias reports whether alias a precedes b in bytewise AliasRelativePath // order. -func LessAlias(a, b Alias) bool { +func lessAlias(a, b Alias) bool { return a.AliasRelativePath < b.AliasRelativePath } -// LessHook reports whether hook a precedes b. Comparison is bytewise on +// lessHook reports whether hook a precedes b. Comparison is bytewise on // (phase, scope.Group, name). The apply orchestrator, not this comparator, // owns final execution order between phases and scopes. -func LessHook(a, b Hook) bool { +func lessHook(a, b Hook) bool { if a.Phase != b.Phase { return a.Phase < b.Phase } @@ -54,27 +44,19 @@ func LessHook(a, b Hook) bool { return a.Name < b.Name } -// SortFiles sorts files in place by LessManagedFile, stably. +// SortFiles sorts files in place by target path, stably. func SortFiles(files []ManagedFile) { - sort.SliceStable(files, indexLess(files, LessManagedFile)) + sort.SliceStable(files, indexLess(files, lessManagedFile)) } -// SortAliases sorts aliases in place by LessAlias, stably. +// SortAliases sorts aliases in place by alias path, stably. func SortAliases(aliases []Alias) { - sort.SliceStable(aliases, indexLess(aliases, LessAlias)) + sort.SliceStable(aliases, indexLess(aliases, lessAlias)) } -// SortHooks sorts hooks in place by LessHook, stably. +// SortHooks sorts hooks in place by phase, scope, and name, stably. func SortHooks(hooks []Hook) { - sort.SliceStable(hooks, indexLess(hooks, LessHook)) -} - -// SortGroups returns a copy of groups sorted in bytewise order with duplicates -// removed. -func SortGroups(groups []string) []string { - sorted := copyStrings(groups) - slices.Sort(sorted) - return compactStrings(sorted) + sort.SliceStable(hooks, indexLess(hooks, lessHook)) } func indexLess[T any](items []T, less func(a, b T) bool) func(int, int) bool { @@ -82,17 +64,3 @@ func indexLess[T any](items []T, less func(a, b T) bool) func(int, int) bool { return less(items[i], items[j]) } } - -func compactStrings(sorted []string) []string { - if len(sorted) == 0 { - return sorted - } - write := 1 - for read := 1; read < len(sorted); read++ { - if sorted[read] != sorted[read-1] { - sorted[write] = sorted[read] - write++ - } - } - return sorted[:write] -} diff --git a/internal/deployment/sort_test.go b/internal/deployment/sort_test.go index 9cfc5a8..cc53b2c 100644 --- a/internal/deployment/sort_test.go +++ b/internal/deployment/sort_test.go @@ -12,13 +12,10 @@ func TestDeploymentOrdering(t *testing.T) { run func(*testing.T) }{ {"empty inputs are safe", testSortEmpty}, - {"root scope sorts first", testRootScopeFirst}, - {"unicode bytewise order", testUnicodeScopeOrder}, {"managed file total order", testManagedFileOrder}, {"managed file tie breakers", testManagedFileTieBreakers}, {"alias bytewise order", testAliasOrder}, {"both hook phases", testHookPhaseOrder}, - {"groups dedup and sort", testGroupsDedup}, {"file permutations converge", testFilePermutations}, {"alias permutations converge", testAliasPermutations}, } @@ -31,27 +28,6 @@ func testSortEmpty(t *testing.T) { SortFiles(nil) SortAliases(nil) SortHooks(nil) - if got := SortGroups(nil); len(got) != 0 { - t.Fatalf("SortGroups(nil) = %v", got) - } -} - -func testRootScopeFirst(t *testing.T) { - if !LessScope(NewScope(""), NewScope("atuin")) { - t.Fatal("root scope must sort before any named group") - } - if LessScope(NewScope("atuin"), NewScope("")) { - t.Fatal("named group must not sort before root scope") - } -} - -func testUnicodeScopeOrder(t *testing.T) { - if !LessScope(NewScope("Apple"), NewScope("äpple")) { - t.Fatal("'Apple' must sort before 'äpple' bytewise") - } - if !LessScope(NewScope("atom"), NewScope("ätzend")) { - t.Fatal("'atom' must sort before 'ätzend' bytewise") - } } func testManagedFileOrder(t *testing.T) { @@ -134,18 +110,6 @@ func testHookPhaseOrder(t *testing.T) { } } -func testGroupsDedup(t *testing.T) { - input := []string{"zsh", "atuin", "zsh", "atuin", "bash"} - got := SortGroups(input) - want := []string{"atuin", "bash", "zsh"} - if !slices.Equal(got, want) { - t.Fatalf("SortGroups = %v, want %v", got, want) - } - if len(input) != 5 || input[0] != "zsh" { - t.Fatalf("SortGroups mutated caller input: %v", input) - } -} - func testFilePermutations(t *testing.T) { base := []ManagedFile{ {TargetRelativePath: "a", Layer: LayerBase, Kind: FileOrdinary}, diff --git a/internal/hooks/order.go b/internal/hooks/order.go index 2aa3f9c..aac4e68 100644 --- a/internal/hooks/order.go +++ b/internal/hooks/order.go @@ -15,7 +15,7 @@ func SortBefore(hooks []deployment.Hook) { return firstHook.Phase == deployment.HookBefore } if firstHook.Phase == deployment.HookBefore { - return LessBefore(firstHook, secondHook) + return lessBefore(firstHook, secondHook) } return false }) @@ -30,15 +30,15 @@ func SortAfter(hooks []deployment.Hook) { return firstHook.Phase == deployment.HookBefore } if firstHook.Phase == deployment.HookAfter { - return LessAfter(firstHook, secondHook) + return lessAfter(firstHook, secondHook) } return false }) } -// LessBefore reports whether a precedes b among before-phase hooks: repository +// lessBefore reports whether a precedes b among before-phase hooks: repository // scope first, then groups bytewise, then names bytewise. -func LessBefore(a, b deployment.Hook) bool { +func lessBefore(a, b deployment.Hook) bool { if a.Scope.Group != b.Scope.Group { if a.Scope.Group == "" { return true @@ -51,9 +51,9 @@ func LessBefore(a, b deployment.Hook) bool { return a.Name < b.Name } -// LessAfter reports whether a precedes b among after-phase hooks: groups +// lessAfter reports whether a precedes b among after-phase hooks: groups // bytewise first, repository scope last, then names bytewise. -func LessAfter(a, b deployment.Hook) bool { +func lessAfter(a, b deployment.Hook) bool { if a.Scope.Group != b.Scope.Group { if a.Scope.Group == "" { return false diff --git a/internal/quality/naming_test.go b/internal/quality/naming_test.go deleted file mode 100644 index 04e0ce8..0000000 --- a/internal/quality/naming_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package quality - -import ( - "go/ast" - "go/token" - "os" - "strings" - "testing" -) - -// The two exact package-variable exceptions permitted by Section 12.1: the -// build-info linker strings and the embedded migration SQL. -var allowedGlobalNames = map[string]bool{ - "Version": true, - "Commit": true, - "BuildTimestamp": true, - "initialMigrationSQL": true, -} - -func forbiddenFileName(base string) bool { - switch base { - case "manager.go", "helpers.go", "utils.go", "common.go", "misc.go": - return true - } - return false -} - -func forbiddenPackageName(name string) bool { - switch name { - case "manager", "helpers", "utils", "common", "misc": - return true - } - return false -} - -// The marker fragments are built by concatenation so the checker's own source -// does not literally contain the patterns it scans for. -func suppressionMarker(line string) bool { - return strings.Contains(line, "//"+nolintFragment) || - strings.Contains(line, "//"+lintIgnoreFragment) || - strings.Contains(line, "//"+reviveFragment) -} - -func generatedMarker(line string) bool { - return strings.Contains(line, "DO NOT "+editFragment) || - strings.Contains(line, "Code "+generatedFragment) -} - -const ( - nolintFragment = "nolint" - lintIgnoreFragment = "lint:ignore" - reviveFragment = "revive:disable" - editFragment = "EDIT" - generatedFragment = "generated" -) - -// forbiddenIdentName flags the local abbreviations Section 12.1 bans. Short -// idiomatic names remain acceptable and are not listed here. -func forbiddenIdentName(name string) bool { - switch name { - case "cfg", "mgr", "svc", "req", "res", "opts", "fsys", "curr", "prev": - return true - } - return false -} - -// namingViolations scans one Go file for the naming and structure rules. -func namingViolations(path string) []violation { - fileSet, file, err := parseSource(path) - if err != nil { - return nil - } - checker := namingChecker{path: path, fileSet: fileSet, production: !isTestPath(path)} - checker.scan(file) - return checker.breaches -} - -type namingChecker struct { - path string - fileSet *token.FileSet - production bool - breaches []violation -} - -func (checker *namingChecker) scan(file *ast.File) { - checker.packageName(file.Name) - for _, declaration := range file.Decls { - checker.declaration(declaration) - } - for _, line := range fileLines(checker.path) { - checker.sourceLine(line) - } -} - -func (checker *namingChecker) packageName(name *ast.Ident) { - if name != nil && forbiddenPackageName(name.Name) { - checker.note(name.Pos(), "forbidden package name") - } -} - -func (checker *namingChecker) declaration(declaration ast.Decl) { - general, ok := declaration.(*ast.GenDecl) - if ok { - checker.genDeclaration(general) - return - } - function, ok := declaration.(*ast.FuncDecl) - if ok && checker.production && function.Name.Name == "init" { - checker.note(function.Pos(), "init function") - } -} - -func (checker *namingChecker) genDeclaration(general *ast.GenDecl) { - if general.Tok == token.VAR && checker.production { - checker.varSpecs(general.Specs) - } - for _, spec := range general.Specs { - if typeSpec, ok := spec.(*ast.TypeSpec); ok { - checker.interfaceName(typeSpec) - } - } -} - -func (checker *namingChecker) varSpecs(specs []ast.Spec) { - for _, spec := range specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if !ok { - continue - } - checker.valueNames(valueSpec.Names) - } -} - -func (checker *namingChecker) valueNames(names []*ast.Ident) { - for _, name := range names { - if !allowedGlobalNames[name.Name] { - checker.note(name.Pos(), "forbidden package global "+name.Name) - } - } -} - -func (checker *namingChecker) interfaceName(spec *ast.TypeSpec) { - if forbiddenIdentName(spec.Name.Name) { - checker.note(spec.Name.Pos(), "abbreviated type name "+spec.Name.Name) - } -} - -func (checker *namingChecker) sourceLine(line string) { - if suppressionMarker(line) { - checker.noteLine(line, "suppression marker") - } - if generatedMarker(line) { - checker.noteLine(line, "generated marker") - } -} - -func (checker *namingChecker) note(position token.Pos, rule string) { - checker.breaches = append(checker.breaches, violation{ - file: checker.path, line: checker.fileSet.Position(position).Line, rule: rule, - }) -} - -func (checker *namingChecker) noteLine(line, rule string) { - checker.breaches = append(checker.breaches, violation{ - file: checker.path, rule: rule + ": " + strings.TrimSpace(line), - }) -} - -func fileLines(path string) []string { - bytes, err := os.ReadFile(path) - if err != nil { - return nil - } - return strings.Split(string(bytes), "\n") -} - -func isTestPath(path string) bool { - return strings.HasSuffix(path, "_test.go") -} - -func TestNamingChecker(t *testing.T) { - scenarios := []struct { - name string - run func(*testing.T) - }{ - {"synthetic bad names", testSyntheticBadNames}, - {"buildinfo globals are the only exception", testAllowedGlobals}, - {"live tree is clean", testLiveNamingClean}, - } - for _, scenario := range scenarios { - t.Run(scenario.name, scenario.run) - } -} - -func testSyntheticBadNames(t *testing.T) { - scenarios := []struct { - rule string - source string - }{ - {"forbidden package global", "package p\nvar cfg = 1\n"}, - {"forbidden package name", "package manager\nvar x = 1\n"}, - {"suppression marker", "package p " + "//" + nolintFragment + "\n"}, - {"generated marker", "package p\n// Code " + generatedFragment + ". DO NOT " + editFragment + ".\n"}, - {"init function", "package p\nfunc init() {}\n"}, - } - for _, scenario := range scenarios { - path := writeTempFile(t, "case.go", scenario.source) - if !anyRuleMatches(namingViolations(path), scenario.rule) { - t.Fatalf("rule %q did not fire", scenario.rule) - } - } -} - -func testAllowedGlobals(t *testing.T) { - scenarios := []string{"Version", "Commit", "BuildTimestamp", "initialMigrationSQL"} - for _, name := range scenarios { - path := writeTempFile(t, "case.go", "package p\nvar "+name+" = \"x\"\n") - if anyRuleMatches(namingViolations(path), "forbidden package global") { - t.Fatalf("allowed global %q was rejected", name) - } - } -} - -func testLiveNamingClean(t *testing.T) { - var breaches []violation - for _, path := range allSources(t) { - if isGoPath(path) { - breaches = append(breaches, namingViolations(path)...) - } - if forbiddenFileName(baseName(path)) { - breaches = append(breaches, violation{file: path, rule: "forbidden file name"}) - } - } - failOn(t, "live naming violations", breaches) -} - -func isGoPath(path string) bool { - return strings.HasSuffix(path, ".go") -} - -func baseName(path string) string { - return path[strings.LastIndex(path, "/")+1:] -} diff --git a/internal/quality/scan_test.go b/internal/quality/scan_test.go index 44d27e4..6094c91 100644 --- a/internal/quality/scan_test.go +++ b/internal/quality/scan_test.go @@ -38,27 +38,6 @@ func repositoryRoot(t *testing.T) string { } } -// walkSourceFiles visits every implementation file beneath root, skipping VCS, -// Nix, and build-output directories. -func walkSourceFiles(t *testing.T, root string, visit func(string)) { - t.Helper() - err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - return skipDirectory(path) - } - if isImplementationSource(path) { - visit(path) - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} - func skipDirectory(path string) error { switch filepath.Base(path) { case ".git", ".direnv", "vendor", "node_modules": @@ -67,24 +46,6 @@ func skipDirectory(path string) error { return nil } -// isImplementationSource reports whether path is a scanned source file. Prose -// documentation, go.sum, and flake.lock are excluded by Section 12.1. -func isImplementationSource(path string) bool { - base := filepath.Base(path) - if base == "go.sum" || base == "flake.lock" || strings.HasSuffix(path, ".md") { - return false - } - if base == "justfile" || base == "Justfile" { - return true - } - for _, extension := range []string{".go", ".sh", ".bash", ".py", ".nix", ".yml", ".yaml", ".sql"} { - if strings.HasSuffix(path, extension) { - return true - } - } - return false -} - func parseSource(path string) (*token.FileSet, *ast.File, error) { fileSet := token.NewFileSet() file, err := parser.ParseFile(fileSet, path, nil, parser.ParseComments) @@ -104,78 +65,3 @@ func failOn(t *testing.T, what string, violations []violation) { } t.Fatal(builder.String()) } - -// writeTempFile writes content beneath a unique temporary directory and returns -// its path for synthetic-rule checks. -func writeTempFile(t *testing.T, name, content string) string { - t.Helper() - directory := t.TempDir() - path := filepath.Join(directory, name) - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { - t.Fatal(err) - } - return path -} - -// assertRuleFails writes source as a Go file and requires shapeViolations to -// name the expected rule at least once. -func assertRuleFails(t *testing.T, rule, source string) { - t.Helper() - path := writeTempFile(t, "case.go", source) - breaches := shapeViolations(path) - if len(breaches) == 0 { - t.Fatalf("rule %q did not fire", rule) - } - if !anyRuleMatches(breaches, rule) { - t.Fatalf("rule %q not in %v", rule, breaches) - } -} - -func anyRuleMatches(breaches []violation, rule string) bool { - for _, breach := range breaches { - if strings.Contains(breach.rule, rule) { - return true - } - } - return false -} - -// fileLengthViolations reports implementation files exceeding the line limit. -func fileLengthViolations(paths []string) []violation { - var breaches []violation - for _, path := range paths { - if count := countLines(path); count > maxFileLines { - breaches = append(breaches, violation{rule: "file length", line: count}) - } - } - return breaches -} - -func countLines(path string) int { - bytes, err := os.ReadFile(path) - if err != nil { - return 0 - } - return strings.Count(string(bytes), "\n") + trailingLine(bytes) -} - -func trailingLine(bytes []byte) int { - if len(bytes) == 0 { - return 0 - } - if bytes[len(bytes)-1] == '\n' { - return 0 - } - return 1 -} - -// allSources collects every implementation source path beneath the repo root. -func allSources(t *testing.T) []string { - t.Helper() - root := repositoryRoot(t) - var paths []string - walkSourceFiles(t, root, func(path string) { - paths = append(paths, path) - }) - return paths -} diff --git a/internal/quality/shape_test.go b/internal/quality/shape_test.go deleted file mode 100644 index b0f8b42..0000000 --- a/internal/quality/shape_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package quality - -import ( - "go/ast" - "go/token" - "strings" -) - -// functionShape tallies statement and decision counts for one function body. -type functionShape struct { - statements int - decisions int -} - -func (shape *functionShape) observe(node ast.Node) { - switch typed := node.(type) { - case *ast.CaseClause: - if len(typed.List) > 0 { - shape.decisions++ - } - case *ast.CommClause: - if typed.Comm != nil { - shape.decisions++ - } - case *ast.BinaryExpr: - if typed.Op == token.LAND || typed.Op == token.LOR { - shape.decisions++ - } - case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt: - shape.decisions++ - } - if _, ok := node.(ast.Stmt); ok { - shape.statements++ - } -} - -func measureBody(body *ast.BlockStmt) functionShape { - var shape functionShape - ast.Inspect(body, func(node ast.Node) bool { - if _, ok := node.(*ast.FuncLit); ok { - return false - } - shape.observe(node) - return true - }) - return shape -} - -// nestingDepth measures the deepest stack of nested control-flow bodies. An -// else-if chain stays at the same depth rather than deepening. -func nestingDepth(body *ast.BlockStmt) int { - deepest := 0 - for _, stmt := range body.List { - depth := controlFlowDepth(stmt) - if depth > deepest { - deepest = depth - } - } - return deepest -} - -func controlFlowDepth(stmt ast.Stmt) int { - bodies := controlBodies(stmt) - if bodies == nil { - return 0 - } - return 1 + maxChildDepth(bodies) -} - -func maxChildDepth(bodies [][]ast.Stmt) int { - deepest := 0 - for _, body := range bodies { - deepest = maxOf(deepest, bodyChildDepth(body)) - } - return deepest -} - -func bodyChildDepth(body []ast.Stmt) int { - deepest := 0 - for _, child := range body { - depth := controlFlowDepth(child) - if depth > deepest { - deepest = depth - } - } - return deepest -} - -func maxOf(first, second int) int { - if first > second { - return first - } - return second -} - -func controlBodies(stmt ast.Stmt) [][]ast.Stmt { - switch node := stmt.(type) { - case *ast.IfStmt: - return ifBodies(node) - case *ast.ForStmt: - return [][]ast.Stmt{node.Body.List} - case *ast.RangeStmt: - return [][]ast.Stmt{node.Body.List} - case *ast.SwitchStmt: - return clauseBodies(node.Body.List) - case *ast.TypeSwitchStmt: - return clauseBodies(node.Body.List) - case *ast.SelectStmt: - return clauseBodies(node.Body.List) - } - return nil -} - -func clauseBodies(list []ast.Stmt) [][]ast.Stmt { - var bodies [][]ast.Stmt - for _, stmt := range list { - if clause, ok := stmt.(*ast.CaseClause); ok { - bodies = append(bodies, clause.Body) - } - if clause, ok := stmt.(*ast.CommClause); ok { - bodies = append(bodies, clause.Body) - } - } - return bodies -} - -func ifBodies(node *ast.IfStmt) [][]ast.Stmt { - bodies := [][]ast.Stmt{node.Body.List} - branch := node.Else - for branch != nil { - chain, isChain := branch.(*ast.IfStmt) - if isChain { - bodies = append(bodies, chain.Body.List) - branch = chain.Else - continue - } - if block, isBlock := branch.(*ast.BlockStmt); isBlock { - bodies = append(bodies, block.List) - } - break - } - return bodies -} - -func parameterCount(fieldList *ast.FieldList) int { - count := 0 - if fieldList == nil { - return count - } - for _, field := range fieldList.List { - names := len(field.Names) - if names == 0 { - count++ - continue - } - count += names - } - return count -} - -func lineSpan(node ast.Node, fileSet *token.FileSet) int { - start := fileSet.Position(node.Pos()).Line - end := fileSet.Position(node.End()).Line - return end - start + 1 -} - -// runELiterals marks function literals assigned to a .RunE field so the -// stricter line limit applies to them. -func runELiterals(file *ast.File) map[token.Pos]bool { - runE := make(map[token.Pos]bool) - for _, decl := range file.Decls { - ast.Inspect(decl, func(node ast.Node) bool { - recordRunEAssignment(node, runE) - return true - }) - } - return runE -} - -func recordRunEAssignment(node ast.Node, runE map[token.Pos]bool) { - assign, ok := node.(*ast.AssignStmt) - if !ok { - return - } - for index, left := range assign.Lhs { - if isRunEField(left) && index < len(assign.Rhs) { - addRunELiteral(assign.Rhs[index], runE) - } - } -} - -func isRunEField(left ast.Expr) bool { - selector, ok := left.(*ast.SelectorExpr) - return ok && selector.Sel.Name == "RunE" -} - -func addRunELiteral(value ast.Expr, runE map[token.Pos]bool) { - if literal, ok := value.(*ast.FuncLit); ok { - runE[literal.Pos()] = true - } -} - -// Synthetic sources for the shape-rule table. Each is valid Go that exceeds one -// named limit so the checker must report it. - -func repeatedStatements(count int) string { - return "package p\nfunc f() {\n" + strings.Repeat("\tx = 1\n", count) + "}\n" -} - -var longFunctionSource = "package p\nfunc long() {\n" + strings.Repeat("\tx = 1\n", maxFunctionLines+5) + "}\n" - -var longRunESource = "package p\ntype command struct{ RunE func() }\n" + - "var instance command\n" + - "func init() { instance.RunE = func() {\n" + - strings.Repeat("\tx = 1\n", maxRunELines+5) + "} }\n" - -var manyStatementsSource = repeatedStatements(maxStatements + 5) - -var manyDecisionsSource = "package p\nfunc f() {\n" + strings.Repeat("\tif true {}\n", maxDecisions+1) + "}\n" - -var deepNestingSource = "package p\nfunc f() {\n\tif true {\n\t\tif true {\n\t\t\tif true {}\n\t\t}\n\t}\n}\n" - -var manyParametersSource = "package p\nfunc f(a, b, c, d int) {}\n" - -var fatInterfaceSource = "package p\ntype Big interface { A(); B(); C(); D() }\n" diff --git a/internal/quality/source_limits_test.go b/internal/quality/source_limits_test.go deleted file mode 100644 index 1f14b21..0000000 --- a/internal/quality/source_limits_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package quality - -import ( - "go/ast" - "go/token" - "path/filepath" - "strings" - "testing" -) - -const ( - maxFileLines = 400 - maxFunctionLines = 40 - maxRunELines = 15 - maxStatements = 25 - maxDecisions = 10 - maxNesting = 2 - maxParameters = 3 - maxInterfaceMethods = 3 -) - -// shapeViolations scans one Go file for function and interface limit breaches. -func shapeViolations(path string) []violation { - fileSet, file, err := parseSource(path) - if err != nil { - return nil - } - runE := runELiterals(file) - var breaches []violation - for _, decl := range file.Decls { - breaches = append(breaches, declViolations(decl, fileSet, runE)...) - } - ast.Inspect(file, func(node ast.Node) bool { - if literal, ok := node.(*ast.FuncLit); ok { - breaches = append(breaches, literalViolations(literal, fileSet, runE)...) - } - return true - }) - breaches = append(breaches, interfaceViolations(file, fileSet)...) - return breaches -} - -// functionContext bundles the inputs needed to measure one function so every -// helper obeys the three-parameter limit. -type functionContext struct { - name string - body *ast.BlockStmt - params *ast.FieldList - node ast.Node - fileSet *token.FileSet - runE map[token.Pos]bool -} - -// measurement captures the computed metrics for one function. -type measurement struct { - length int - limit int - shape functionShape - start int -} - -func declViolations(decl ast.Decl, fileSet *token.FileSet, runE map[token.Pos]bool) []violation { - function, ok := decl.(*ast.FuncDecl) - if !ok || function.Body == nil { - return nil - } - context := functionContext{ - name: function.Name.Name, body: function.Body, - params: function.Type.Params, node: function, fileSet: fileSet, runE: runE, - } - return functionViolations(context) -} - -func literalViolations(literal *ast.FuncLit, fileSet *token.FileSet, runE map[token.Pos]bool) []violation { - if literal.Body == nil { - return nil - } - name := "func-literal" - if runE[literal.Pos()] { - name = "RunE-literal" - } - context := functionContext{ - name: name, body: literal.Body, params: literal.Type.Params, - node: literal, fileSet: fileSet, runE: runE, - } - return functionViolations(context) -} - -func functionViolations(context functionContext) []violation { - return buildViolations(context, measureFunction(context)) -} - -func measureFunction(context functionContext) measurement { - limit := maxFunctionLines - if context.name == "RunE" || context.name == "RunE-literal" || context.runE[context.node.Pos()] { - limit = maxRunELines - } - return measurement{ - length: lineSpan(context.node, context.fileSet), - limit: limit, - shape: measureBody(context.body), - start: context.fileSet.Position(context.node.Pos()).Line, - } -} - -func buildViolations(context functionContext, measured measurement) []violation { - var breaches []violation - if measured.length > measured.limit { - breaches = append(breaches, violation{rule: context.name + " length", line: measured.start}) - } - if measured.shape.statements > maxStatements { - breaches = append(breaches, violation{rule: context.name + " statements", line: measured.start}) - } - if measured.shape.decisions > maxDecisions { - breaches = append(breaches, violation{rule: context.name + " decisions", line: measured.start}) - } - if nestingDepth(context.body) > maxNesting { - breaches = append(breaches, violation{rule: context.name + " nesting", line: measured.start}) - } - if parameterCount(context.params) > maxParameters { - breaches = append(breaches, violation{rule: context.name + " parameters", line: measured.start}) - } - return breaches -} - -// interfaceViolations rejects any interface whose transitive method count, -// including embedded same-package interfaces, exceeds the limit. -func interfaceViolations(file *ast.File, fileSet *token.FileSet) []violation { - set := collectInterfaces(file) - var breaches []violation - for name, source := range set { - count := countInterfaceMethods(name, set, map[string]bool{}) - if count > maxInterfaceMethods { - breaches = append(breaches, violation{rule: "interface " + name + " methods", line: fileSet.Position(source.Pos()).Line}) - } - } - return breaches -} - -func collectInterfaces(file *ast.File) map[string]*ast.InterfaceType { - set := make(map[string]*ast.InterfaceType) - for _, decl := range file.Decls { - declaration, ok := decl.(*ast.GenDecl) - if !ok || declaration.Tok != token.TYPE { - continue - } - mergeTypeSpecs(declaration, set) - } - return set -} - -func mergeTypeSpecs(declaration *ast.GenDecl, set map[string]*ast.InterfaceType) { - for _, spec := range declaration.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - interfaceType, ok := typeSpec.Type.(*ast.InterfaceType) - if !ok { - continue - } - set[typeSpec.Name.Name] = interfaceType - } -} - -func countInterfaceMethods(name string, set map[string]*ast.InterfaceType, seen map[string]bool) int { - if seen[name] { - return 0 - } - source, ok := set[name] - if !ok { - return 0 - } - seen[name] = true - total := 0 - for _, field := range source.Methods.List { - total += methodContribution(field, set, seen) - } - return total -} - -func methodContribution(field *ast.Field, set map[string]*ast.InterfaceType, seen map[string]bool) int { - if len(field.Names) > 0 { - return len(field.Names) - } - ident, ok := field.Type.(*ast.Ident) - if !ok { - return 1 - } - return countInterfaceMethods(ident.Name, set, seen) -} - -func TestSourceShapeChecker(t *testing.T) { - t.Run("synthetic bad snippets", func(t *testing.T) { - assertRuleFails(t, "length", longFunctionSource) - assertRuleFails(t, "RunE-literal", longRunESource) - assertRuleFails(t, "statements", manyStatementsSource) - assertRuleFails(t, "decisions", manyDecisionsSource) - assertRuleFails(t, "nesting", deepNestingSource) - assertRuleFails(t, "parameters", manyParametersSource) - assertRuleFails(t, "interface", fatInterfaceSource) - }) - - t.Run("file length limit", func(t *testing.T) { - path := writeTempFile(t, "long.go", strings.Repeat("x()\n", maxFileLines+5)) - breaches := fileLengthViolations([]string{path}) - if len(breaches) != 1 { - t.Fatalf("expected one file-length breach, got %d", len(breaches)) - } - }) - - t.Run("live tree is clean", func(t *testing.T) { - liveTreeHasNoShapeOrLengthBreaches(t) - }) -} - -func liveTreeHasNoShapeOrLengthBreaches(t *testing.T) { - t.Helper() - var breaches []violation - for _, path := range allSources(t) { - breaches = append(breaches, fileLengthViolations([]string{path})...) - if filepath.Ext(path) == ".go" { - breaches = append(breaches, shapeViolations(path)...) - } - } - failOn(t, "live shape or length violations", breaches) -} diff --git a/internal/state/aliases.go b/internal/state/aliases.go index 11d9e51..c8aeafe 100644 --- a/internal/state/aliases.go +++ b/internal/state/aliases.go @@ -21,8 +21,6 @@ ON CONFLICT(repository_id, alias_path) DO UPDATE SET const aliasRetireSQL = "UPDATE aliases SET status = 'retired', retired_at = ? WHERE repository_id = ? AND alias_path = ?" -const aliasReactivateSQL = "UPDATE aliases SET status = 'active', retired_at = NULL, applied_at = ? WHERE repository_id = ? AND alias_path = ?" - const aliasByPairPathSQL = ` SELECT ` + aliasColumns + ` FROM aliases a JOIN repositories r ON r.id = a.repository_id @@ -33,19 +31,10 @@ SELECT ` + aliasColumns + ` FROM aliases a JOIN repositories r ON r.id = a.repository_id WHERE r.root_path = ? AND r.home_path = ? ORDER BY a.alias_path` -const activeAliasBaselinesSQL = ` -SELECT ` + aliasColumns + ` -FROM aliases a JOIN repositories r ON r.id = a.repository_id -WHERE r.root_path = ? AND r.home_path = ? AND a.status = 'active' ORDER BY a.alias_path` - const allAliasGroupsSQL = ` SELECT DISTINCT group_name FROM aliases a JOIN repositories r ON r.id = a.repository_id WHERE r.root_path = ? AND r.home_path = ? ORDER BY group_name` -const activeAliasGroupsSQL = ` -SELECT DISTINCT group_name FROM aliases a JOIN repositories r ON r.id = a.repository_id -WHERE r.root_path = ? AND r.home_path = ? AND a.status = 'active' ORDER BY group_name` - // aliasBaselineKey identifies one row by canonical pair and alias path. type aliasBaselineKey struct { root, home, alias string @@ -80,12 +69,6 @@ func (store *Store) RetireAliasBaseline(root, home, aliasPath string) (AliasBase return store.setAliasStatus(aliasBaselineKey{root: root, home: home, alias: aliasPath}, aliasRetireSQL) } -// ReactivateAliasBaseline restores a retired row to active without touching -// its retained payload, which the caller reconciles against. -func (store *Store) ReactivateAliasBaseline(root, home, aliasPath string) (AliasBaseline, error) { - return store.setAliasStatus(aliasBaselineKey{root: root, home: home, alias: aliasPath}, aliasReactivateSQL) -} - // AliasBaseline reads one alias row of the canonical pair. func (store *Store) AliasBaseline(root, home, aliasPath string) (AliasBaseline, error) { root, home, err := canonicalRepositoryPair(root, home) @@ -115,15 +98,6 @@ func (store *Store) AliasBaselines(root, home string) ([]AliasBaseline, error) { return store.readAliasBaselines(allAliasBaselinesSQL, root, home) } -// ActiveAliasBaselines reads the active alias rows of the canonical pair. -func (store *Store) ActiveAliasBaselines(root, home string) ([]AliasBaseline, error) { - root, home, err := canonicalRepositoryPair(root, home) - if err != nil { - return nil, err - } - return store.readAliasBaselines(activeAliasBaselinesSQL, root, home) -} - // AliasGroups lists the distinct group names of any alias row, so an // explicitly selected retired-only group remains valid. func (store *Store) AliasGroups(root, home string) ([]string, error) { @@ -133,13 +107,3 @@ func (store *Store) AliasGroups(root, home string) ([]string, error) { } return store.readAliasGroups(allAliasGroupsSQL, root, home) } - -// ActiveAliasGroups lists the distinct group names with an active alias row, -// so no-argument selection can exclude retired-only groups. -func (store *Store) ActiveAliasGroups(root, home string) ([]string, error) { - root, home, err := canonicalRepositoryPair(root, home) - if err != nil { - return nil, err - } - return store.readAliasGroups(activeAliasGroupsSQL, root, home) -} diff --git a/internal/state/aliases_decode.go b/internal/state/aliases_decode.go deleted file mode 100644 index 42fff80..0000000 --- a/internal/state/aliases_decode.go +++ /dev/null @@ -1,50 +0,0 @@ -package state - -import ( - "fmt" - "time" -) - -func scanAliasBaseline(source scanner) (AliasBaseline, error) { - var baseline AliasBaseline - var raw aliasRawRow - err := source.Scan(&baseline.RepositoryID, &baseline.AliasPath, &baseline.CanonicalTargetPath, - &baseline.GroupName, &raw.layer, &raw.status, &raw.applied, &raw.retired) - if err != nil { - return AliasBaseline{}, fmt.Errorf("state: scan alias baseline: %w", err) - } - if err := decodeAliasBaseline(&baseline, raw); err != nil { - return AliasBaseline{}, err - } - return baseline, nil -} - -// aliasRawRow carries the stored text form of one aliases row. -type aliasRawRow struct { - layer, status, applied string - retired *string -} - -func decodeAliasBaseline(baseline *AliasBaseline, raw aliasRawRow) error { - layer, err := ParseAliasLayer(raw.layer) - if err != nil { - return err - } - status, err := ParseSourceStatus(raw.status) - if err != nil { - return err - } - appliedAt, err := time.Parse(time.RFC3339Nano, raw.applied) - if err != nil { - return err - } - retiredAt, err := decodeOptionalTimestamp(raw.retired) - if err != nil { - return err - } - baseline.Layer = layer - baseline.Status = status - baseline.AppliedAt = appliedAt - baseline.RetiredAt = retiredAt - return nil -} diff --git a/internal/state/aliases_read.go b/internal/state/aliases_read.go index fd60fc6..f642a04 100644 --- a/internal/state/aliases_read.go +++ b/internal/state/aliases_read.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/alyraffauf/cattery/internal/pathsafe" ) @@ -112,3 +113,47 @@ func (store *Store) readAliasGroups(statement, root, home string) ([]string, err func errMissingAliasBaseline(aliasPath string) error { return fmt.Errorf("state: no alias baseline for path %q", aliasPath) } + +func scanAliasBaseline(source scanner) (AliasBaseline, error) { + var baseline AliasBaseline + var raw aliasRawRow + err := source.Scan(&baseline.RepositoryID, &baseline.AliasPath, &baseline.CanonicalTargetPath, + &baseline.GroupName, &raw.layer, &raw.status, &raw.applied, &raw.retired) + if err != nil { + return AliasBaseline{}, fmt.Errorf("state: scan alias baseline: %w", err) + } + if err := decodeAliasBaseline(&baseline, raw); err != nil { + return AliasBaseline{}, err + } + return baseline, nil +} + +// aliasRawRow carries the stored text form of one aliases row. +type aliasRawRow struct { + layer, status, applied string + retired *string +} + +func decodeAliasBaseline(baseline *AliasBaseline, raw aliasRawRow) error { + layer, err := ParseAliasLayer(raw.layer) + if err != nil { + return err + } + status, err := ParseSourceStatus(raw.status) + if err != nil { + return err + } + appliedAt, err := time.Parse(time.RFC3339Nano, raw.applied) + if err != nil { + return err + } + retiredAt, err := decodeOptionalTimestamp(raw.retired) + if err != nil { + return err + } + baseline.Layer = layer + baseline.Status = status + baseline.AppliedAt = appliedAt + baseline.RetiredAt = retiredAt + return nil +} diff --git a/internal/state/aliases_retire_test.go b/internal/state/aliases_retire_test.go index e00726b..edf47c2 100644 --- a/internal/state/aliases_retire_test.go +++ b/internal/state/aliases_retire_test.go @@ -42,42 +42,6 @@ func testAliasRetirementRequiresRow(t *testing.T) { } } -func testAliasReactivation(t *testing.T) { - store := openStore(t, tempDependencies(t)) - root := t.TempDir() - home := t.TempDir() - seedAlias(t, store, aliasSpec{root: root, home: home, alias: ".once", canonical: ".config/once"}) - if _, err := store.RetireAliasBaseline(root, home, ".once"); err != nil { - t.Fatalf("retire: %v", err) - } - restored, err := store.ReactivateAliasBaseline(root, home, ".once") - requireNoError(t, err) - if restored.Status != StatusActive || restored.RetiredAt != nil { - t.Fatal("reactivation left status or timestamp behind") - } - if restored.CanonicalTargetPath != ".config/once" { - t.Fatalf("reactivation lost the payload: %+v", restored) - } -} - -func testAliasDeterministicReads(t *testing.T) { - store := openStore(t, tempDependencies(t)) - root := t.TempDir() - home := t.TempDir() - seedAlias(t, store, aliasSpec{root: root, home: home, alias: ".z", canonical: ".config/z"}) - seedAlias(t, store, aliasSpec{root: root, home: home, alias: ".a", canonical: ".config/a", group: "zeta"}) - seedAlias(t, store, aliasSpec{root: root, home: home, alias: ".m", canonical: ".config/m", group: "alpha"}) - if _, err := store.RetireAliasBaseline(root, home, ".a"); err != nil { - t.Fatalf("retire: %v", err) - } - if groups, err := store.AliasGroups(root, home); err != nil || len(groups) != 3 || groups[0] != "" || groups[1] != "alpha" || groups[2] != "zeta" { - t.Fatalf("AliasGroups = %v (%v), want ['', alpha, zeta]", groups, err) - } - if active, err := store.ActiveAliasGroups(root, home); err != nil || len(active) != 2 || active[0] != "" || active[1] != "alpha" { - t.Fatalf("ActiveAliasGroups = %v (%v), want ['', alpha]", active, err) - } -} - func testAliasRollback(t *testing.T) { store := openStore(t, tempDependencies(t)) root := t.TempDir() @@ -105,9 +69,6 @@ func testAliasDualActiveCorruption(t *testing.T) { if _, err := store.AliasBaselines(root, home); err == nil { t.Fatal("alias snapshot accepted a dual-active path") } - if _, err := store.ActiveAliasBaselines(root, home); err == nil { - t.Fatal("active alias snapshot accepted a dual-active path") - } } func testAliasColumnCount(t *testing.T) { diff --git a/internal/state/aliases_test.go b/internal/state/aliases_test.go index 303da21..9e4ab2a 100644 --- a/internal/state/aliases_test.go +++ b/internal/state/aliases_test.go @@ -17,8 +17,6 @@ func TestAliasRows(t *testing.T) { {"baseline validation rejects malformed rows", testAliasValidationRejects}, {"selected retirement marks only the chosen row", testAliasSelectedRetirement}, {"retirement requires an existing row", testAliasRetirementRequiresRow}, - {"reactivation restores a retired row", testAliasReactivation}, - {"reads are deterministic and scopes are state-only", testAliasDeterministicReads}, {"rollback discards the whole transaction", testAliasRollback}, {"dual-active corruption fails the snapshot", testAliasDualActiveCorruption}, {"no plaintext column exists", testAliasColumnCount}, diff --git a/internal/state/database.go b/internal/state/database.go index 5306e18..cfad549 100644 --- a/internal/state/database.go +++ b/internal/state/database.go @@ -79,21 +79,6 @@ func (database *Database) Close() error { return err } -// ResolveDatabasePath resolves the canonical absolute path of the SQLite state -// database beneath $XDG_STATE_HOME/cattery/state.db. A relative XDG_STATE_HOME -// is rejected rather than resolved against the working directory. -func ResolveDatabasePath() (string, error) { - home, err := resolveStateHome("") - if err != nil { - return "", err - } - directory, err := resolveCatteryDirectory(home) - if err != nil { - return "", err - } - return filepath.Join(directory, stateDatabaseFileName), nil -} - // resolveStateHome returns the validated state home. An explicit absolute path // overrides the environment; an empty explicit reads XDG_STATE_HOME and rejects // a relative value before falling back to the XDG default. diff --git a/internal/state/database_test.go b/internal/state/database_test.go index 4c9562d..f1f40e8 100644 --- a/internal/state/database_test.go +++ b/internal/state/database_test.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "syscall" "testing" ) @@ -25,7 +24,6 @@ func TestDatabaseOpen(t *testing.T) { {"rejects wrong file mode", testRejectsWrongFileMode}, {"open failure leaves no connection", testOpenFailureLeavesNoConnection}, {"rejects relative state home", testRejectsRelativeStateHome}, - {"resolves cattery database path", testResolvesCatteryDatabasePath}, } for _, scenario := range scenarios { t.Run(scenario.name, scenario.run) @@ -140,9 +138,6 @@ func testOpenFailureLeavesNoConnection(t *testing.T) { func testRejectsRelativeStateHome(t *testing.T) { t.Setenv("XDG_STATE_HOME", "relative/path") - if _, err := ResolveDatabasePath(); err == nil { - t.Fatal("ResolveDatabasePath accepted a relative XDG_STATE_HOME") - } if _, err := resolveStateHome("relative/path"); err == nil { t.Fatal("resolveStateHome accepted a relative explicit home") } @@ -156,17 +151,6 @@ func testRejectsRelativeStateHome(t *testing.T) { } } -func testResolvesCatteryDatabasePath(t *testing.T) { - path, err := ResolveDatabasePath() - if err != nil { - t.Fatalf("ResolveDatabasePath: %v", err) - } - suffix := "/" + catteryDirectoryName + "/" + stateDatabaseFileName - if !strings.HasSuffix(path, suffix) { - t.Fatalf("ResolveDatabasePath = %q, want suffix %q", path, suffix) - } -} - func tempDatabasePath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), catteryDirectoryName, stateDatabaseFileName) diff --git a/internal/state/files.go b/internal/state/files.go index 6a8d7b6..b78487f 100644 --- a/internal/state/files.go +++ b/internal/state/files.go @@ -27,8 +27,6 @@ ON CONFLICT(repository_id, target_path) DO UPDATE SET const fileRetireSQL = "UPDATE files SET status = 'retired', retired_at = ? WHERE repository_id = ? AND target_path = ?" -const fileReactivateSQL = "UPDATE files SET status = 'active', retired_at = NULL, applied_at = ? WHERE repository_id = ? AND target_path = ?" - const fileByPairTargetSQL = ` SELECT ` + fileColumns + ` FROM files f JOIN repositories r ON r.id = f.repository_id @@ -39,11 +37,6 @@ SELECT ` + fileColumns + ` FROM files f JOIN repositories r ON r.id = f.repository_id WHERE r.root_path = ? AND r.home_path = ? ORDER BY f.target_path` -const activeFileBaselinesSQL = ` -SELECT ` + fileColumns + ` -FROM files f JOIN repositories r ON r.id = f.repository_id -WHERE r.root_path = ? AND r.home_path = ? AND f.status = 'active' ORDER BY f.target_path` - // dualActiveByPairSQL lists paths active in both representations of a pair, // which the schema cannot express as a constraint (PLAN.md Section 8.4). const dualActiveByPairSQL = ` @@ -57,10 +50,6 @@ const allFileGroupsSQL = ` SELECT DISTINCT group_name FROM files f JOIN repositories r ON r.id = f.repository_id WHERE r.root_path = ? AND r.home_path = ? ORDER BY group_name` -const activeFileGroupsSQL = ` -SELECT DISTINCT group_name FROM files f JOIN repositories r ON r.id = f.repository_id -WHERE r.root_path = ? AND r.home_path = ? AND f.status = 'active' ORDER BY group_name` - // fileBaselineKey identifies one row by canonical pair and target. type fileBaselineKey struct { root, home, target string @@ -102,12 +91,6 @@ func (store *Store) RetireFileBaseline(root, home, target string) (FileBaseline, return store.setFileStatus(fileBaselineKey{root: root, home: home, target: target}, fileRetireSQL) } -// ReactivateFileBaseline restores a retired row to active without touching its -// retained baseline, which the caller reconciles against. -func (store *Store) ReactivateFileBaseline(root, home, target string) (FileBaseline, error) { - return store.setFileStatus(fileBaselineKey{root: root, home: home, target: target}, fileReactivateSQL) -} - func (store *Store) setFileStatus(key fileBaselineKey, statement string) (FileBaseline, error) { repository, err := store.requireRepository(key.root, key.home) if err != nil { @@ -153,15 +136,6 @@ func (store *Store) FileBaselines(root, home string) ([]FileBaseline, error) { return store.readFileBaselines(allFileBaselinesSQL, root, home) } -// ActiveFileBaselines reads the active file rows of the canonical pair. -func (store *Store) ActiveFileBaselines(root, home string) ([]FileBaseline, error) { - root, home, err := canonicalRepositoryPair(root, home) - if err != nil { - return nil, err - } - return store.readFileBaselines(activeFileBaselinesSQL, root, home) -} - // FileGroups lists the distinct group names of any file row, so an explicitly // selected retired-only group remains valid. func (store *Store) FileGroups(root, home string) ([]string, error) { @@ -171,13 +145,3 @@ func (store *Store) FileGroups(root, home string) ([]string, error) { } return store.readFileGroups(allFileGroupsSQL, root, home) } - -// ActiveFileGroups lists the distinct group names with an active file row, so -// no-argument selection can exclude retired-only groups. -func (store *Store) ActiveFileGroups(root, home string) ([]string, error) { - root, home, err := canonicalRepositoryPair(root, home) - if err != nil { - return nil, err - } - return store.readFileGroups(activeFileGroupsSQL, root, home) -} diff --git a/internal/state/files_decode.go b/internal/state/files_decode.go deleted file mode 100644 index ee1e1f9..0000000 --- a/internal/state/files_decode.go +++ /dev/null @@ -1,106 +0,0 @@ -package state - -import ( - "fmt" - "time" - - "github.com/alyraffauf/cattery/internal/deployment" -) - -func scanFileBaseline(source scanner) (FileBaseline, error) { - var baseline FileBaseline - var raw fileRawRow - err := source.Scan(&baseline.RepositoryID, &baseline.TargetPath, &baseline.GroupName, - &baseline.SourcePath, &raw.kind, &raw.layer, &raw.contentHash, &raw.sourceHash, - &raw.executable, &raw.status, &raw.applied, &raw.retired) - if err != nil { - return FileBaseline{}, fmt.Errorf("state: scan file baseline: %w", err) - } - if err := decodeFileBaseline(&baseline, raw); err != nil { - return FileBaseline{}, err - } - return baseline, nil -} - -type fileRawRow struct { - kind, layer, status, applied string - retired *string - contentHash, sourceHash []byte - executable int64 -} - -func decodeFileBaseline(baseline *FileBaseline, raw fileRawRow) error { - if err := decodeFileEnums(baseline, raw); err != nil { - return err - } - if err := decodeFileTimes(baseline, raw); err != nil { - return err - } - content, err := decodeDigest(raw.contentHash) - if err != nil { - return err - } - sourceHash, err := decodeDigest(raw.sourceHash) - if err != nil { - return err - } - baseline.BaselineContentHash = content - baseline.BaselineSourceHash = sourceHash - baseline.ExecutableBits = uint32(raw.executable) - return nil -} - -func decodeFileEnums(baseline *FileBaseline, raw fileRawRow) error { - kind, err := deployment.ParseFileKind(raw.kind) - if err != nil { - return err - } - layer, err := deployment.ParseLayer(raw.layer) - if err != nil { - return err - } - status, err := ParseSourceStatus(raw.status) - if err != nil { - return err - } - baseline.SourceKind = kind - baseline.Layer = layer - baseline.Status = status - return nil -} - -func decodeFileTimes(baseline *FileBaseline, raw fileRawRow) error { - appliedAt, err := time.Parse(time.RFC3339Nano, raw.applied) - if err != nil { - return err - } - retiredAt, err := decodeOptionalTimestamp(raw.retired) - if err != nil { - return err - } - baseline.AppliedAt = appliedAt - baseline.RetiredAt = retiredAt - return nil -} - -func decodeOptionalTimestamp(raw *string) (*time.Time, error) { - if raw == nil { - return nil, nil - } - parsed, err := time.Parse(time.RFC3339Nano, *raw) - if err != nil { - return nil, fmt.Errorf("state: retired_at: %w", err) - } - return &parsed, nil -} - -func errMissingFileBaseline(target string) error { - return fmt.Errorf("state: no file baseline for target %q", target) -} - -// errDualActiveRepresentation reports corruption: paths active in both tables. -func errDualActiveRepresentation(count int) error { - return fmt.Errorf( - "state: %d paths are active in both files and aliases; reset state to repair", - count) -} diff --git a/internal/state/files_read.go b/internal/state/files_read.go index ca63c9d..0e28506 100644 --- a/internal/state/files_read.go +++ b/internal/state/files_read.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "fmt" + "time" "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/pathsafe" @@ -179,3 +180,101 @@ func (store *Store) queryStrings(statement, label string, args ...any) ([]string } return values, nil } + +func scanFileBaseline(source scanner) (FileBaseline, error) { + var baseline FileBaseline + var raw fileRawRow + err := source.Scan(&baseline.RepositoryID, &baseline.TargetPath, &baseline.GroupName, + &baseline.SourcePath, &raw.kind, &raw.layer, &raw.contentHash, &raw.sourceHash, + &raw.executable, &raw.status, &raw.applied, &raw.retired) + if err != nil { + return FileBaseline{}, fmt.Errorf("state: scan file baseline: %w", err) + } + if err := decodeFileBaseline(&baseline, raw); err != nil { + return FileBaseline{}, err + } + return baseline, nil +} + +type fileRawRow struct { + kind, layer, status, applied string + retired *string + contentHash, sourceHash []byte + executable int64 +} + +func decodeFileBaseline(baseline *FileBaseline, raw fileRawRow) error { + if err := decodeFileEnums(baseline, raw); err != nil { + return err + } + if err := decodeFileTimes(baseline, raw); err != nil { + return err + } + content, err := decodeDigest(raw.contentHash) + if err != nil { + return err + } + sourceHash, err := decodeDigest(raw.sourceHash) + if err != nil { + return err + } + baseline.BaselineContentHash = content + baseline.BaselineSourceHash = sourceHash + baseline.ExecutableBits = uint32(raw.executable) + return nil +} + +func decodeFileEnums(baseline *FileBaseline, raw fileRawRow) error { + kind, err := deployment.ParseFileKind(raw.kind) + if err != nil { + return err + } + layer, err := deployment.ParseLayer(raw.layer) + if err != nil { + return err + } + status, err := ParseSourceStatus(raw.status) + if err != nil { + return err + } + baseline.SourceKind = kind + baseline.Layer = layer + baseline.Status = status + return nil +} + +func decodeFileTimes(baseline *FileBaseline, raw fileRawRow) error { + appliedAt, err := time.Parse(time.RFC3339Nano, raw.applied) + if err != nil { + return err + } + retiredAt, err := decodeOptionalTimestamp(raw.retired) + if err != nil { + return err + } + baseline.AppliedAt = appliedAt + baseline.RetiredAt = retiredAt + return nil +} + +func decodeOptionalTimestamp(raw *string) (*time.Time, error) { + if raw == nil { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339Nano, *raw) + if err != nil { + return nil, fmt.Errorf("state: retired_at: %w", err) + } + return &parsed, nil +} + +func errMissingFileBaseline(target string) error { + return fmt.Errorf("state: no file baseline for target %q", target) +} + +// errDualActiveRepresentation reports corruption: paths active in both tables. +func errDualActiveRepresentation(count int) error { + return fmt.Errorf( + "state: %d paths are active in both files and aliases; reset state to repair", + count) +} diff --git a/internal/state/files_retire_test.go b/internal/state/files_retire_test.go index 03573b2..86f10e3 100644 --- a/internal/state/files_retire_test.go +++ b/internal/state/files_retire_test.go @@ -63,41 +63,6 @@ func testFileRetirementRequiresRow(t *testing.T) { } } -func testFileReactivation(t *testing.T) { - store := openStore(t, tempDependencies(t)) - root := t.TempDir() - home := t.TempDir() - seedOrdinary(t, store, seedSpec{root: root, home: home, target: ".once", fill: 0x41}) - if _, err := store.RetireFileBaseline(root, home, ".once"); err != nil { - t.Fatalf("retire: %v", err) - } - restored, err := store.ReactivateFileBaseline(root, home, ".once") - if err != nil { - t.Fatalf("ReactivateFileBaseline: %v", err) - } - if restored.Status != StatusActive || restored.RetiredAt != nil { - t.Fatal("reactivation left status or timestamp behind") - } -} - -func testFileDeterministicReads(t *testing.T) { - store := openStore(t, tempDependencies(t)) - root := t.TempDir() - home := t.TempDir() - seedOrdinary(t, store, seedSpec{root: root, home: home, target: ".z", fill: 0x91}) - seedOrdinary(t, store, seedSpec{root: root, home: home, target: ".a", group: "zeta", fill: 0x92}) - seedOrdinary(t, store, seedSpec{root: root, home: home, target: ".m", group: "alpha", fill: 0x93}) - if _, err := store.RetireFileBaseline(root, home, ".a"); err != nil { - t.Fatalf("retire: %v", err) - } - if groups, err := store.FileGroups(root, home); err != nil || len(groups) != 3 || groups[0] != "" || groups[1] != "alpha" || groups[2] != "zeta" { - t.Fatalf("FileGroups = %v (%v), want ['', alpha, zeta]", groups, err) - } - if active, err := store.ActiveFileGroups(root, home); err != nil || len(active) != 2 || active[0] != "" || active[1] != "alpha" { - t.Fatalf("ActiveFileGroups = %v (%v), want ['', alpha]", active, err) - } -} - func testFileRollback(t *testing.T) { store := openStore(t, tempDependencies(t)) root := t.TempDir() @@ -125,9 +90,6 @@ func testFileDualActiveCorruption(t *testing.T) { if _, err := store.FileBaselines(root, home); err == nil { t.Fatal("snapshot accepted a dual-active path") } - if _, err := store.ActiveFileBaselines(root, home); err == nil { - t.Fatal("active snapshot accepted a dual-active path") - } } func testFileNoPlaintextColumn(t *testing.T) { diff --git a/internal/state/files_test.go b/internal/state/files_test.go index b7e2ab9..529eac8 100644 --- a/internal/state/files_test.go +++ b/internal/state/files_test.go @@ -19,8 +19,6 @@ func TestFileRows(t *testing.T) { {"secret baselines create the key and commit its identifier", testFileSecretBaseline}, {"selected retirement marks only the chosen row", testFileSelectedRetirement}, {"retirement requires an existing row", testFileRetirementRequiresRow}, - {"reactivation restores a retired row", testFileReactivation}, - {"reads are deterministic and scopes are state-only", testFileDeterministicReads}, {"rollback discards the whole transaction", testFileRollback}, {"dual-active corruption fails the snapshot", testFileDualActiveCorruption}, {"no plaintext column exists", testFileNoPlaintextColumn}, diff --git a/internal/state/lock.go b/internal/state/lock.go index ad29b52..5c7c8e0 100644 --- a/internal/state/lock.go +++ b/internal/state/lock.go @@ -3,7 +3,6 @@ package state import ( "fmt" "os" - "path/filepath" "strconv" "syscall" @@ -69,21 +68,6 @@ func (lock *Lock) Release() error { return err } -// ResolveLockPath resolves the canonical absolute path of the advisory lock -// beneath $XDG_STATE_HOME/cattery/cattery.lock. A relative XDG_STATE_HOME is -// rejected rather than resolved against the working directory. -func ResolveLockPath() (string, error) { - home, err := resolveStateHome("") - if err != nil { - return "", err - } - directory, err := resolveCatteryDirectory(home) - if err != nil { - return "", err - } - return filepath.Join(directory, stateLockFileName), nil -} - // writeProcessID writes the current PID to the lock file for diagnostics and // restores the required file mode so a restrictive umask cannot widen it. func writeProcessID(path string) error { diff --git a/internal/state/recovery_test.go b/internal/state/recovery_test.go index 5f6f8e5..6cde428 100644 --- a/internal/state/recovery_test.go +++ b/internal/state/recovery_test.go @@ -44,9 +44,9 @@ type baselineSeed struct { // insertSecretBaseline inserts one secret file row for the registered pair. func insertSecretBaseline(t *testing.T, store *Store, seed baselineSeed) { t.Helper() - repository, err := store.RegisterRepository(seed.root, seed.home) + repository, err := store.SetDefaultRepository(seed.root, seed.home) if err != nil { - t.Fatalf("RegisterRepository: %v", err) + t.Fatalf("register repository: %v", err) } execOn(t, store.Database().conn, fmt.Sprintf( "INSERT INTO files (repository_id, target_path, group_name, source_path, source_kind, layer, baseline_content_hash, baseline_source_hash, executable_bits, status, applied_at) VALUES (%d, '%s', '', 'secrets/%s', 'secret', 'base', X'0101010101010101010101010101010101010101010101010101010101010101', X'0202020202020202020202020202020202020202020202020202020202020202', 384, 'active', '2026-01-02T03:04:05Z')", diff --git a/internal/state/repositories.go b/internal/state/repositories.go index 71842c0..5b3e41b 100644 --- a/internal/state/repositories.go +++ b/internal/state/repositories.go @@ -47,29 +47,6 @@ SELECT ` + repositoryColumns + ` FROM repositories ORDER BY root_path, home_path` -// RegisterRepository records the canonical (root, home) pair and refreshes its -// last-seen timestamp, returning the stored row. Re-registering an existing -// pair is idempotent: the row id, creation time, and default flag survive. -func (store *Store) RegisterRepository(root, home string) (Repository, error) { - root, home, err := canonicalRepositoryPair(root, home) - if err != nil { - return Repository{}, err - } - now := formatTimestamp(store.now()) - transaction, err := store.database.conn.Begin() - if err != nil { - return Repository{}, err - } - if err := execIn(transaction, repositoryUpsertSQL, root, home, now, now); err != nil { - return Repository{}, err - } - repository, err := scanAndCommit(transaction, root, home) - if err != nil { - return Repository{}, err - } - return repository, nil -} - // SetDefaultRepository registers the canonical pair and promotes it to the // sole default of its home in one transaction, demoting any previous default. func (store *Store) SetDefaultRepository(root, home string) (Repository, error) { diff --git a/internal/state/repositories_test.go b/internal/state/repositories_test.go index c483488..13af61d 100644 --- a/internal/state/repositories_test.go +++ b/internal/state/repositories_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "testing" "time" ) @@ -14,8 +15,6 @@ func TestRepositoryRows(t *testing.T) { name string run func(*testing.T) }{ - {"registers canonical pair with timestamps", testRepositoryRegisters}, - {"re-registration preserves identity", testRepositoryReRegistration}, {"lookup never registers", testRepositoryLookupDoesNotRegister}, {"default replacement keeps one per home", testRepositoryDefaultReplacement}, {"defaults across two homes", testRepositoryTwoHomes}, @@ -28,46 +27,6 @@ func TestRepositoryRows(t *testing.T) { } } -func testRepositoryRegisters(t *testing.T) { - clock := &pinnedClock{now: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)} - store := openStore(t, Dependencies{StateHome: t.TempDir(), Now: clock.Now}) - root := t.TempDir() - home := t.TempDir() - first, err := store.RegisterRepository(root, home) - if err != nil { - t.Fatalf("RegisterRepository: %v", err) - } - assertRepositoryPair(t, first, Repository{RootPath: root, HomePath: home}) - if first.IsDefault { - t.Fatal("registration created a default") - } - if !first.CreatedAt.Equal(clock.now) || !first.LastSeenAt.Equal(clock.now) { - t.Fatalf("timestamps = %v/%v, want %v", first.CreatedAt, first.LastSeenAt, clock.now) - } -} - -func testRepositoryReRegistration(t *testing.T) { - clock := &pinnedClock{now: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)} - store := openStore(t, Dependencies{StateHome: t.TempDir(), Now: clock.Now}) - root := t.TempDir() - home := t.TempDir() - first, err := store.RegisterRepository(root, home) - if err != nil { - t.Fatalf("RegisterRepository: %v", err) - } - clock.now = clock.now.Add(time.Hour) - second, err := store.RegisterRepository(root, home) - if err != nil { - t.Fatalf("re-register: %v", err) - } - if second.ID != first.ID { - t.Fatalf("re-registration changed row id %d -> %d", first.ID, second.ID) - } - if !second.CreatedAt.Equal(first.CreatedAt) || !second.LastSeenAt.Equal(clock.now) { - t.Fatal("re-registration changed created_at or last_seen_at") - } -} - func testRepositoryLookupDoesNotRegister(t *testing.T) { store := openStore(t, tempDependencies(t)) root := t.TempDir() @@ -78,8 +37,8 @@ func testRepositoryLookupDoesNotRegister(t *testing.T) { if count := rowCount(t, store.Database().conn, "repositories"); count != 0 { t.Fatalf("lookup registered %d rows", count) } - if _, err := store.RegisterRepository(root, home); err != nil { - t.Fatalf("RegisterRepository: %v", err) + if _, err := store.SetDefaultRepository(root, home); err != nil { + t.Fatalf("register repository: %v", err) } found, err := store.LookupRepository(root, home) if err != nil { @@ -162,8 +121,9 @@ func testRepositorySnapshotOrdering(t *testing.T) { store := openStore(t, tempDependencies(t)) home := t.TempDir() roots := []string{t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir()} - for _, root := range roots { - if _, err := store.RegisterRepository(root, home); err != nil { + for index, root := range roots { + if _, err := store.UpsertFileBaseline(root, home, + ordinaryBaseline(".file"+strconv.Itoa(index), "", byte(index+1))); err != nil { t.Fatalf("register %q: %v", root, err) } }