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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- shell completion no longer crashes with an index-out-of-range panic when
pressing tab after the last positional argument of a command
- Commands that accept names for ids (`in`, `out`, `edit`, `delete`,
`report`, etc.) now fail with a list of matching candidates when a name
matches more than one project, task, client or tag, instead of silently
using the first match

## [v0.64.3] - 2026-09-17

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ go-install: deps-install ## install dev version
go install $(MAIN_PKG)

go-generate: deps-install ## recreates generate files
go install github.com/vektra/mockery/v3@v3.4.0
go install github.com/vektra/mockery/v3@v3.8.0
mockery

test-install: deps-install go-generate
Expand Down
87 changes: 48 additions & 39 deletions internal/mocks/mock_Client.go

Large diffs are not rendered by default.

33 changes: 21 additions & 12 deletions internal/mocks/mock_Config.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion internal/mocks/mock_Factory.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions pkg/cmd/time-entry/util/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ func getProjectID(

if found == -1 {
if projectID != "" {
fmt.Printf("Project '%s' informed was not found.\n", projectID)
fmt.Printf(
"Project '%s' informed was not found or is ambiguous, choose one:\n",
projectID)
projectID = ""
}
} else {
Expand Down Expand Up @@ -224,7 +226,9 @@ func getTaskID(

if found == -1 {
if taskID != "" {
fmt.Printf("Task '%s' informed was not found.\n", taskID)
fmt.Printf(
"Task '%s' informed was not found or is ambiguous, choose one:\n",
taskID)
taskID = ""
}
} else {
Expand Down
52 changes: 52 additions & 0 deletions pkg/search/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,55 @@ func (e ErrNotFound) Error() string {
return "No " + e.EntityName + " with id or name containing '" +
e.Reference + "' was found" + sufix
}

// Candidate is an entity that matched an ambiguous reference
type Candidate struct {
ID string
Name string
}

// maxCandidatesInError keeps the ambiguous error message readable when the
// reference matches a long list of entities
const maxCandidatesInError = 10

// ErrAmbiguous represents a reference that matched more than one entity, the
// caller must be more specific to identify which one it meant
type ErrAmbiguous struct {
EntityName string
Reference string
Candidates []Candidate
}

func (e ErrAmbiguous) Error() string {
cs := make([]string, len(e.Candidates))
for i, c := range e.Candidates {
cs[i] = "'" + c.Name + "' (" + c.ID + ")"
}

return "More than one " + e.EntityName +
" with id or name containing '" + e.Reference + "' was found: " +
strhlp.LimitedListForHumans(cs, maxCandidatesInError)
}

func newErrAmbiguous(entityName, reference string, matches []named) ErrAmbiguous {
cs := make([]Candidate, len(matches))
for i, m := range matches {
cs[i] = Candidate{ID: m.GetID(), Name: m.GetName()}
}

// the api listing order is not stable, sort so the message reads the
// same across runs
sort.Slice(cs, func(i, j int) bool {
if cs[i].Name == cs[j].Name {
return cs[i].ID < cs[j].ID
}

return cs[i].Name < cs[j].Name
})

return ErrAmbiguous{
EntityName: entityName,
Reference: reference,
Candidates: cs,
}
}
49 changes: 43 additions & 6 deletions pkg/search/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,56 @@ func findByName(
return r, err
}

isSimilar := strhlp.IsSimilar(name)
for _, e := range l {
if strings.ToLower(e.GetID()) == name {
return e.GetID(), nil
}
}

if isSimilar(e.GetName()) {
return e.GetID(), nil
var exact, similar []named
isSimilar := strhlp.IsSimilar(name)
for _, e := range l {
switch {
case strhlp.Normalize(e.GetName()) == name:
exact = append(exact, e)
case isSimilar(e.GetName()):
similar = append(similar, e)
}
}

matches := exact
if len(matches) == 0 {
matches = similar
}

// the list may repeat an entity, e.g. the client of several projects,
// repeated ids are not ambiguity
matches = uniqueByID(matches)

switch len(matches) {
case 0:
return r, ErrNotFound{
EntityName: entityName,
Reference: r,
}
case 1:
return matches[0].GetID(), nil
default:
return r, newErrAmbiguous(entityName, r, matches)
}
}

return r, ErrNotFound{
EntityName: entityName,
Reference: r,
func uniqueByID(l []named) []named {
seen := make(map[string]bool, len(l))
u := make([]named, 0, len(l))
for _, e := range l {
if seen[e.GetID()] {
continue
}

seen[e.GetID()] = true
u = append(u, e)
}

return u
}
Loading
Loading