Skip to content
5 changes: 5 additions & 0 deletions .changes/unreleased/Added-20260612-195753.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Added
body: Each database instance can be named via 'db name' command
time: 2026-06-12T19:57:53.467217151-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Added-20260612-195846.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Added
body: '''info'' command shows inventory information and sums entities by status'
time: 2026-06-12T19:58:46.067873164-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Added-20260612-234254.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Added
body: (TUI) detail pane can be toggled
time: 2026-06-12T23:42:54.25848273-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Changed-20260613-000537.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Changed
body: (TUI) history is shown in right pane as a toggle
time: 2026-06-13T00:05:37.127335509-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Fixed-20260612-215537.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: Removed extra vertical spaces between entites in file list
time: 2026-06-12T21:55:37.811554046-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Fixed-20260612-231534.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: '`--create-parents` actually creates missing ancestors'
time: 2026-06-12T23:15:34.333119832-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Fixed-20260612-233119.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: (TUI) history shows each entity event
time: 2026-06-12T23:31:19.024623359-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Fixed-20260614-005257.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: (TUI) scry shows list of matched entities
time: 2026-06-14T00:52:57.700667096-04:00
custom:
Issue: ""
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ A typed iota enum classifying which layer a `DoctorIssue` belongs to: `config`,

The top-level verdict of a `doctor` run. `true` when no `DoctorIssue`s were found across all checks; `false` otherwise. Exposed as `healthy` in `--json` output. Determines exit code regardless of output mode.

### WherehouseName

A user-assigned display label for a database file, stored in `schema_metadata` under the key `"display_name"`. Independent of the filename. Set via `wherehouse db name <display name>`; cleared via `wherehouse db name --clear`. Never parsed as a path or entity name. When unset, displays as `(unnamed)`. Max 255 characters; newlines are rejected.

---

## Terms to avoid
Expand Down
12 changes: 7 additions & 5 deletions cmd/add/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func runAdd(cmd *cobra.Command, args []string, a *app.App) error {
locked, _ := cmd.Flags().GetBool("locked")
discrete, _ := cmd.Flags().GetBool("discrete")
allowDupes, _ := cmd.Flags().GetBool("allow-duplicates")
createParents, _ := cmd.Flags().GetBool("create-parents")

if !allowDupes {
seen := make(map[string]struct{}, len(args))
Expand Down Expand Up @@ -102,11 +103,12 @@ func runAdd(cmd *cobra.Command, args []string, a *app.App) error {
return fmt.Errorf("cannot determine entity name from %q", arg)
}
reqs = append(reqs, app.CreateEntityRequest{
DisplayName: name,
Locked: locked,
Discrete: discrete,
ParentPath: p.Dir().String(),
ActorID: cli.GetActorUserID(ctx),
DisplayName: name,
Locked: locked,
Discrete: discrete,
ParentPath: p.Dir().String(),
ActorID: cli.GetActorUserID(ctx),
CreateParents: createParents,
})
}

Expand Down
77 changes: 77 additions & 0 deletions cmd/add/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,83 @@ func TestRunAdd_File_CreateParents_CreatesAncestors(t *testing.T) {
assert.True(t, paths["Garage:Toolbox:Wrench"])
}

func TestRunAdd_CreateParents_CreatesAncestors(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()

cmd := add.NewAddCmd(a)
cmd.SetArgs([]string{"Dining Area:Buffet", "--create-parents"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

entities, err := a.ListEntities(ctx)
require.NoError(t, err)
paths := make(map[string]bool)
for _, e := range entities {
paths[e.FullPathDisplay] = true
}
assert.True(t, paths["Dining Area"], "parent should be created")
assert.True(t, paths["Dining Area:Buffet"], "leaf should be created")
}

func TestRunAdd_CreateParents_ThreeLevels(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()

cmd := add.NewAddCmd(a)
cmd.SetArgs([]string{"A:B:C", "--create-parents"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

entities, err := a.ListEntities(ctx)
require.NoError(t, err)
paths := make(map[string]bool)
for _, e := range entities {
paths[e.FullPathDisplay] = true
}
assert.True(t, paths["A"])
assert.True(t, paths["A:B"])
assert.True(t, paths["A:B:C"])
}

func TestRunAdd_CreateParents_ExistingParent(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()

_, err := a.CreateEntity(ctx, app.CreateEntityRequest{DisplayName: "Garage", ActorID: "test"})
require.NoError(t, err)

cmd := add.NewAddCmd(a)
cmd.SetArgs([]string{"Garage:Toolbox", "--create-parents"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

entities, err := a.ListEntities(ctx)
require.NoError(t, err)
paths := make(map[string]bool)
for _, e := range entities {
paths[e.FullPathDisplay] = true
}
assert.True(t, paths["Garage"])
assert.True(t, paths["Garage:Toolbox"])
}

func TestRunAdd_WithoutCreateParents_MissingParentErrors(t *testing.T) {
a := apptesting.OpenApp(t)

cmd := add.NewAddCmd(a)
cmd.SetArgs([]string{"Dining Area:Buffet"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})

err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}

// writeTempCSV writes content to a temp file and returns the path.
func writeTempCSV(t *testing.T, content string) string {
t.Helper()
Expand Down
116 changes: 116 additions & 0 deletions cmd/db/db.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package db

import (
"errors"
"fmt"

"github.com/spf13/cobra"

"github.com/asphaltbuffet/wherehouse/internal/app"
"github.com/asphaltbuffet/wherehouse/internal/cli"
"github.com/asphaltbuffet/wherehouse/internal/config"
)

// NewDefaultDBCmd wires the db command group for production use.
func NewDefaultDBCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "db",
Short: "Manage database settings",
}
cmd.AddCommand(NewDefaultDBNameCmd())
return cmd
}

// NewDBCmd creates the `db` parent command with the `name` subcommand injected with a.
func NewDBCmd(a *app.App) *cobra.Command {
cmd := &cobra.Command{
Use: "db",
Short: "Manage database settings",
}
cmd.AddCommand(NewDBNameCmd(a))
return cmd
}

// NewDefaultDBNameCmd wires the db name command for production use.
func NewDefaultDBNameCmd() *cobra.Command {
cmd := buildDBNameCmd()
cmd.RunE = func(cmd *cobra.Command, args []string) error {
s, a, err := cli.OpenDatabase(cmd.Context())
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer s.Close()
return runDBName(cmd, args, a)
}
return cmd
}

// NewDBNameCmd creates the `db name` subcommand injected with a.
func NewDBNameCmd(a *app.App) *cobra.Command {
cmd := buildDBNameCmd()
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runDBName(cmd, args, a)
}
return cmd
}

func buildDBNameCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "name [display name]",
Short: "Set the display name of this database",
Long: `Set a human-readable display name for this database file.

The name is stored inside the database and is independent of the filename.

Examples:
wherehouse db name "123 Fake Street - House"
wherehouse db name "123 Fake Street - House" --db ~/inventories/rental.db
wherehouse db name --clear`,
Args: cobra.MaximumNArgs(1),
}
cmd.Flags().Bool("clear", false, "remove the display name")
return cmd
}

func runDBName(cmd *cobra.Command, args []string, a *app.App) error {
ctx := cmd.Context()
clearFlag, _ := cmd.Flags().GetBool("clear")

if clearFlag && len(args) > 0 {
return errors.New("--clear cannot be combined with a name argument")
}
if !clearFlag && len(args) == 0 {
return errors.New("provide a display name or use --clear")
}

if clearFlag {
if err := a.ClearWherehouseName(ctx); err != nil {
return fmt.Errorf("clear name: %w", err)
}
} else {
if err := a.SetWherehouseName(ctx, args[0]); err != nil {
return fmt.Errorf("set name: %w", err)
}
}

cfg, ok := cli.GetConfig(ctx)
if !ok {
cfg = config.GetDefaults()
}
out := cli.NewOutputWriterFromConfig(cmd.OutOrStdout(), cmd.ErrOrStderr(), cfg)

if out.IsJSON() {
info, err := a.GetInfo(ctx)
if err != nil {
return fmt.Errorf("get info for json: %w", err)
}
return out.JSON(app.ToInfoOutput(info))
}

if clearFlag {
out.Success("Display name cleared")
} else {
out.Success(fmt.Sprintf("Display name set to %q", args[0]))
}
return nil
}
78 changes: 78 additions & 0 deletions cmd/db/db_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package db_test

import (
"bytes"
"context"
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

dbcmd "github.com/asphaltbuffet/wherehouse/cmd/db"
"github.com/asphaltbuffet/wherehouse/internal/apptesting"
"github.com/asphaltbuffet/wherehouse/internal/config"
)

func TestDbNameCmd_SetsName(t *testing.T) {
a := apptesting.OpenApp(t)
cmd := dbcmd.NewDBCmd(a)
cmd.SetArgs([]string{"name", "My Workshop"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

info, err := a.GetInfo(t.Context())
require.NoError(t, err)
assert.Equal(t, "My Workshop", info.Name)
}

func TestDbNameCmd_ClearsName(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()

require.NoError(t, a.SetWherehouseName(ctx, "Old Name"))

cmd := dbcmd.NewDBCmd(a)
cmd.SetArgs([]string{"name", "--clear"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

info, err := a.GetInfo(ctx)
require.NoError(t, err)
assert.Equal(t, "(unnamed)", info.Name)
}

func TestDbNameCmd_EmptyArgRejected(t *testing.T) {
a := apptesting.OpenApp(t)
cmd := dbcmd.NewDBCmd(a)
cmd.SetArgs([]string{"name", ""})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
assert.Error(t, cmd.Execute())
}

func TestDbNameCmd_ClearAndArgMutuallyExclusive(t *testing.T) {
a := apptesting.OpenApp(t)
cmd := dbcmd.NewDBCmd(a)
cmd.SetArgs([]string{"name", "--clear", "Some Name"})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
assert.Error(t, cmd.Execute())
}

func TestDbNameCmd_JSON(t *testing.T) {
a := apptesting.OpenApp(t)
out := &bytes.Buffer{}
cmd := dbcmd.NewDBCmd(a)
cmd.SetArgs([]string{"name", "Test Name"})
cmd.SetContext(context.WithValue(t.Context(), config.ConfigKey, apptesting.NewTestConfig(t, apptesting.WithJSON())))
cmd.SetOut(out)
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

var result map[string]any
require.NoError(t, json.Unmarshal(out.Bytes(), &result))
assert.Equal(t, "Test Name", result["name"])
}
2 changes: 2 additions & 0 deletions cmd/db/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package db implements the `wherehouse db` command group for database management.
package db
2 changes: 2 additions & 0 deletions cmd/info/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package info implements the `wherehouse info` command.
package info
Loading
Loading