Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/unreleased/Changed-20260614-235443.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Changed
body: (TUI) Navigation uses nested entities with tree
time: 2026-06-14T23:54:43.959238986-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Fixed-20260615-203149.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: (TUI) User can select entities in scry results list
time: 2026-06-15T20:31:49.904001962-04:00
custom:
Issue: ""
5 changes: 5 additions & 0 deletions .changes/unreleased/Fixed-20260616-030351.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: Storage errors (DB unavailable, context cancelled) no longer surface as "entity not found"; they are now propagated with their original cause
time: 2026-06-16T03:03:51Z
custom:
Issue: ""
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ The normalized form of a name used for matching. Rules (from `CanonicalizeString

**Do not confuse `CanonicalName` with `FullPathCanonical`.** Use `FullPathDisplay` when checking depth or path structure in `EntityResult`.

### PathResolution

The act of translating a user-supplied colon-separated path string into an `entity_id` by canonical-name lookup. PathResolution is a CLI/UI concern — the app layer's `LookupEntityByPath` method is the single authoritative entry point. Internal mutations (rename, reparent, remove, status change, tag, history) accept only `entity_id`; CLI commands that receive a path from the user perform PathResolution before constructing a request. Web and TUI callers that already hold an `entity_id` bypass PathResolution entirely.

### Scry

The `scry` command searches for entities by name (fuzzy, using Levenshtein distance). It is a **search/find** command — not an inference engine for missing items. Results are `FindResult` values with `Entity` and `Distance` fields.
Expand Down
4 changes: 2 additions & 2 deletions cmd/add/add_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestRunAdd_LockedFlag(t *testing.T) {
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

result, err := a.GetEntityByPath(ctx, "Garage")
result, err := a.LookupEntityByPath(ctx, "Garage")
require.NoError(t, err)
assert.True(t, result.Locked)
assert.False(t, result.Discrete)
Expand All @@ -79,7 +79,7 @@ func TestRunAdd_DiscreteFlag(t *testing.T) {
cmd.SetErr(&bytes.Buffer{})
require.NoError(t, cmd.Execute())

result, err := a.GetEntityByPath(ctx, "Box of Nails")
result, err := a.LookupEntityByPath(ctx, "Box of Nails")
require.NoError(t, err)
assert.False(t, result.Locked)
assert.True(t, result.Discrete)
Expand Down
20 changes: 15 additions & 5 deletions cmd/borrow/borrow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ func TestRunBorrow_BlocksLost(t *testing.T) {
})
require.NoError(t, err)

drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill")
require.NoError(t, err)
_, err = a.MarkLost(t.Context(), []app.ChangeStatusRequest{
{EntityPath: "Shelf:Drill", Status: inventory.EntityStatusMissing, ActorID: "test"},
{EntityID: drill.EntityID, Status: inventory.EntityStatusMissing, ActorID: "test"},
})
assert.ErrorContains(t, err, "borrowed")
}
Expand All @@ -124,8 +126,10 @@ func TestRunBorrow_BlocksFound(t *testing.T) {
})
require.NoError(t, err)

drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill")
require.NoError(t, err)
_, err = a.MarkFound(t.Context(), []app.ChangeStatusRequest{
{EntityPath: "Shelf:Drill", Status: inventory.EntityStatusOk, ActorID: "test"},
{EntityID: drill.EntityID, Status: inventory.EntityStatusOk, ActorID: "test"},
})
assert.ErrorContains(t, err, "borrowed")
}
Expand All @@ -139,8 +143,10 @@ func TestRunBorrow_BlocksLoan(t *testing.T) {
})
require.NoError(t, err)

drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill")
require.NoError(t, err)
_, err = a.MarkLoaned(t.Context(), []app.ChangeStatusRequest{
{EntityPath: "Shelf:Drill", Status: inventory.EntityStatusLoaned, ActorID: "test"},
{EntityID: drill.EntityID, Status: inventory.EntityStatusLoaned, ActorID: "test"},
})
assert.ErrorContains(t, err, "borrowed")
}
Expand All @@ -154,8 +160,10 @@ func TestRunBorrow_BlocksRemove(t *testing.T) {
})
require.NoError(t, err)

drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill")
require.NoError(t, err)
err = a.RemoveEntity(t.Context(), app.RemoveEntityRequest{
EntityPath: "Shelf:Drill", ActorID: "test",
EntityID: drill.EntityID, ActorID: "test",
})
assert.ErrorContains(t, err, "borrowed")
}
Expand All @@ -169,8 +177,10 @@ func TestRunBorrow_ReturnSetsRemoved(t *testing.T) {
})
require.NoError(t, err)

drill, err := a.LookupEntityByPath(t.Context(), "Shelf:Drill")
require.NoError(t, err)
_, err = a.MarkReturned(t.Context(), []app.ChangeStatusRequest{
{EntityPath: "Shelf:Drill", ActorID: "test"},
{EntityID: drill.EntityID, ActorID: "test"},
})
require.NoError(t, err)

Expand Down
12 changes: 8 additions & 4 deletions cmd/found/found.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ func runFound(cmd *cobra.Command, args []string, a *app.App) error {

reqs := make([]app.ChangeStatusRequest, len(args))
for i, path := range args {
entity, err := a.LookupEntityByPath(ctx, path)
if err != nil {
return fmt.Errorf("failed to find %q: %w", path, err)
}
reqs[i] = app.ChangeStatusRequest{
EntityPath: path,
Status: inventory.EntityStatusOk,
Note: noteFlag,
ActorID: cli.GetActorUserID(ctx),
EntityID: entity.EntityID,
Status: inventory.EntityStatusOk,
Note: noteFlag,
ActorID: cli.GetActorUserID(ctx),
}
}

Expand Down
18 changes: 12 additions & 6 deletions cmd/found/found_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ func seedForFound(t *testing.T, a *app.App) {
require.NoError(t, err)
}
// Mark both as missing so found has something to recover
_, err := a.MarkLost(ctx, []app.ChangeStatusRequest{
{EntityPath: "Garage:Toolbox:Wrench", Status: inventory.EntityStatusMissing, ActorID: "test"},
{EntityPath: "Garage:Toolbox:Hammer", Status: inventory.EntityStatusMissing, ActorID: "test"},
wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench")
require.NoError(t, err)
hammer, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Hammer")
require.NoError(t, err)
_, err = a.MarkLost(ctx, []app.ChangeStatusRequest{
{EntityID: wrench.EntityID, Status: inventory.EntityStatusMissing, ActorID: "test"},
{EntityID: hammer.EntityID, Status: inventory.EntityStatusMissing, ActorID: "test"},
})
require.NoError(t, err)
}
Expand Down Expand Up @@ -116,10 +120,12 @@ func TestRunFound_WorksOnLockedEntity(t *testing.T) {
require.NoError(t, err)

// Manually put it in missing state via ChangeStatus (not locked, so allowed)
wrench, err := a.LookupEntityByPath(ctx, "Garage:Wrench")
require.NoError(t, err)
_, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{
EntityPath: "Garage:Wrench",
Status: inventory.EntityStatusMissing,
ActorID: "test",
EntityID: wrench.EntityID,
Status: inventory.EntityStatusMissing,
ActorID: "test",
})
require.NoError(t, err)

Expand Down
11 changes: 9 additions & 2 deletions cmd/history/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@ Examples:

func runHistory(cmd *cobra.Command, args []string, a *app.App) error {
ctx := cmd.Context()
events, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityPath: args[0]})
path := args[0]

entity, err := a.LookupEntityByPath(ctx, path)
if err != nil {
return fmt.Errorf("failed to find %q: %w", path, err)
}

events, err := a.GetHistory(ctx, app.GetHistoryRequest{EntityID: entity.EntityID})
if err != nil {
return fmt.Errorf("failed to get history for %q: %w", args[0], err)
return fmt.Errorf("failed to get history for %q: %w", path, err)
}

cfg, ok := cli.GetConfig(ctx)
Expand Down
60 changes: 36 additions & 24 deletions cmd/list/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ func TestRunList_ShowsStatusBadge(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()
seedThree(t, a)
_, err := a.ChangeStatus(ctx, app.ChangeStatusRequest{
EntityPath: "Garage:Toolbox:Wrench",
Status: inventory.EntityStatusMissing,
ActorID: "test",
wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench")
require.NoError(t, err)
_, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{
EntityID: wrench.EntityID,
Status: inventory.EntityStatusMissing,
ActorID: "test",
})
require.NoError(t, err)

Expand All @@ -99,10 +101,12 @@ func TestRunList_FilterPrunesUnrelatedBranches(t *testing.T) {
ActorID: "test",
})
require.NoError(t, err)
drill, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Drill")
require.NoError(t, err)
_, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{
EntityPath: "Garage:Toolbox:Drill",
Status: inventory.EntityStatusMissing,
ActorID: "test",
EntityID: drill.EntityID,
Status: inventory.EntityStatusMissing,
ActorID: "test",
})
require.NoError(t, err)

Expand All @@ -117,10 +121,12 @@ func TestRunList_FilterKeepsAncestor(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()
seedThree(t, a)
_, err := a.ChangeStatus(ctx, app.ChangeStatusRequest{
EntityPath: "Garage:Toolbox:Wrench",
Status: inventory.EntityStatusMissing,
ActorID: "test",
wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench")
require.NoError(t, err)
_, err = a.ChangeStatus(ctx, app.ChangeStatusRequest{
EntityID: wrench.EntityID,
Status: inventory.EntityStatusMissing,
ActorID: "test",
})
require.NoError(t, err)

Expand Down Expand Up @@ -149,10 +155,12 @@ func TestRunList_VerboseShowsIDAndTags(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()
seedThree(t, a)
wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench")
require.NoError(t, err)
require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{
EntityPath: "Garage:Toolbox:Wrench",
ActorID: "test",
Add: []string{"dewalt"},
EntityID: wrench.EntityID,
ActorID: "test",
Add: []string{"dewalt"},
}))

out := runCmd(t, a, "--verbose")
Expand All @@ -165,10 +173,12 @@ func TestRunList_NoTagsWithoutVerbose(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()
seedThree(t, a)
wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench")
require.NoError(t, err)
require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{
EntityPath: "Garage:Toolbox:Wrench",
ActorID: "test",
Add: []string{"dewalt"},
EntityID: wrench.EntityID,
ActorID: "test",
Add: []string{"dewalt"},
}))

out := runCmd(t, a)
Expand All @@ -181,10 +191,12 @@ func TestRunList_JSONIncludesTags(t *testing.T) {
a := apptesting.OpenApp(t)
ctx := t.Context()
seedThree(t, a)
wrench, err := a.LookupEntityByPath(ctx, "Garage:Toolbox:Wrench")
require.NoError(t, err)
require.NoError(t, a.TagEntity(ctx, app.TagEntityRequest{
EntityPath: "Garage:Toolbox:Wrench",
ActorID: "test",
Add: []string{"dewalt"},
EntityID: wrench.EntityID,
ActorID: "test",
Add: []string{"dewalt"},
}))

cmd := listcmd.NewListCmd(a)
Expand All @@ -197,16 +209,16 @@ func TestRunList_JSONIncludesTags(t *testing.T) {
var items []map[string]any
require.NoError(t, json.Unmarshal(stdout.Bytes(), &items))

var wrench map[string]any
var wrenchItem map[string]any
for _, item := range items {
if item["path"] == "Garage:Toolbox:Wrench" {
wrench = item
wrenchItem = item
break
}
}
require.NotNil(t, wrench, "Wrench not found in JSON output")
require.NotNil(t, wrenchItem, "Wrench not found in JSON output")

tags, ok := wrench["tags"].([]any)
tags, ok := wrenchItem["tags"].([]any)
require.True(t, ok, "tags field must be an array")
require.Len(t, tags, 1)
assert.Equal(t, "dewalt", tags[0])
Expand Down
6 changes: 5 additions & 1 deletion cmd/loan/loan.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ func runLoan(cmd *cobra.Command, args []string, a *app.App) error {

reqs := make([]app.ChangeStatusRequest, len(args))
for i, path := range args {
entity, err := a.LookupEntityByPath(ctx, path)
if err != nil {
return fmt.Errorf("failed to find %q: %w", path, err)
}
reqs[i] = app.ChangeStatusRequest{
EntityPath: path,
EntityID: entity.EntityID,
Status: inventory.EntityStatusLoaned,
StatusContext: toFlag,
Note: noteFlag,
Expand Down
12 changes: 8 additions & 4 deletions cmd/lost/lost.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ func runLost(cmd *cobra.Command, args []string, a *app.App) error {

reqs := make([]app.ChangeStatusRequest, len(args))
for i, path := range args {
entity, err := a.LookupEntityByPath(ctx, path)
if err != nil {
return fmt.Errorf("failed to find %q: %w", path, err)
}
reqs[i] = app.ChangeStatusRequest{
EntityPath: path,
Status: inventory.EntityStatusMissing,
Note: noteFlag,
ActorID: cli.GetActorUserID(ctx),
EntityID: entity.EntityID,
Status: inventory.EntityStatusMissing,
Note: noteFlag,
ActorID: cli.GetActorUserID(ctx),
}
}

Expand Down
16 changes: 13 additions & 3 deletions cmd/move/move.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,20 @@ func runMove(cmd *cobra.Command, args []string, a *app.App) error {
srcPath := args[0]
destPath, _ := cmd.Flags().GetString("to")

src, err := a.LookupEntityByPath(ctx, srcPath)
if err != nil {
return fmt.Errorf("failed to find %q: %w", srcPath, err)
}

dest, err := a.LookupEntityByPath(ctx, destPath)
if err != nil {
return fmt.Errorf("failed to find destination %q: %w", destPath, err)
}

updated, err := a.ReparentEntity(ctx, app.ReparentEntityRequest{
EntityPath: srcPath,
NewParentPath: destPath,
ActorID: cli.GetActorUserID(ctx),
EntityID: src.EntityID,
NewParentID: dest.EntityID,
ActorID: cli.GetActorUserID(ctx),
})
if err != nil {
return fmt.Errorf("failed to move %q: %w", srcPath, err)
Expand Down
16 changes: 11 additions & 5 deletions cmd/remove/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,17 @@ func runRemove(cmd *cobra.Command, args []string, a *app.App) error {
path := args[0]
noteFlag, _ := cmd.Flags().GetString("note")

if err := a.RemoveEntity(ctx, app.RemoveEntityRequest{
EntityPath: path,
ActorID: cli.GetActorUserID(ctx),
Note: noteFlag,
}); err != nil {
entity, err := a.LookupEntityByPath(ctx, path)
if err != nil {
return fmt.Errorf("failed to find %q: %w", path, err)
}

err = a.RemoveEntity(ctx, app.RemoveEntityRequest{
EntityID: entity.EntityID,
ActorID: cli.GetActorUserID(ctx),
Note: noteFlag,
})
if err != nil {
return fmt.Errorf("failed to remove %q: %w", path, err)
}

Expand Down
11 changes: 8 additions & 3 deletions cmd/rename/rename.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,15 @@ func runRename(cmd *cobra.Command, args []string, a *app.App) error {
path := args[0]
toFlag, _ := cmd.Flags().GetString("to")

entity, err := a.LookupEntityByPath(ctx, path)
if err != nil {
return fmt.Errorf("failed to find %q: %w", path, err)
}

updated, err := a.RenameEntity(ctx, app.RenameEntityRequest{
EntityPath: path,
NewName: toFlag,
ActorID: cli.GetActorUserID(ctx),
EntityID: entity.EntityID,
NewName: toFlag,
ActorID: cli.GetActorUserID(ctx),
})
if err != nil {
return fmt.Errorf("failed to rename %q: %w", path, err)
Expand Down
Loading
Loading