Skip to content

Commit 9e83dfe

Browse files
committed
feat(cli): addec cache
Signed-off-by: olivier dubo <olivier.dubo@ovhcloud.com>
1 parent c090705 commit 9e83dfe

3 files changed

Lines changed: 165 additions & 1 deletion

File tree

internal/completion/cache.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package completion
6+
7+
import (
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
)
13+
14+
// completionCacheTTL is how long cached completion results remain valid.
15+
//
16+
// Shell completion runs as a brand new, short-lived process on every <tab>, so
17+
// an in-memory cache would be useless: results are cached on disk to avoid
18+
// hitting the API on every keystroke.
19+
const completionCacheTTL = 10 * time.Minute
20+
21+
// completionCacheDir returns the directory where completion results are cached,
22+
// honouring XDG_CACHE_HOME. It returns "" if it cannot be determined.
23+
func completionCacheDir() string {
24+
base := os.Getenv("XDG_CACHE_HOME")
25+
if base == "" {
26+
home, err := os.UserHomeDir()
27+
if err != nil {
28+
return ""
29+
}
30+
base = filepath.Join(home, ".cache")
31+
}
32+
return filepath.Join(base, "ovhcloud", "completion")
33+
}
34+
35+
// readCachedSuggestions returns the cached suggestions for the given key when a
36+
// cache file exists and is younger than completionCacheTTL.
37+
func readCachedSuggestions(key string) ([]string, bool) {
38+
dir := completionCacheDir()
39+
if dir == "" {
40+
return nil, false
41+
}
42+
43+
path := filepath.Join(dir, key)
44+
info, err := os.Stat(path)
45+
if err != nil || time.Since(info.ModTime()) > completionCacheTTL {
46+
return nil, false
47+
}
48+
49+
data, err := os.ReadFile(path)
50+
if err != nil {
51+
return nil, false
52+
}
53+
54+
trimmed := strings.Trim(string(data), "\n")
55+
if trimmed == "" {
56+
return []string{}, true
57+
}
58+
return strings.Split(trimmed, "\n"), true
59+
}
60+
61+
// writeCachedSuggestions stores suggestions for the given key. It is best-effort:
62+
// any error (no cache dir, write failure...) is silently ignored so completion
63+
// never fails because of the cache. The write is atomic (temp file + rename).
64+
func writeCachedSuggestions(key string, suggestions []string) {
65+
dir := completionCacheDir()
66+
if dir == "" {
67+
return
68+
}
69+
if err := os.MkdirAll(dir, 0o755); err != nil {
70+
return
71+
}
72+
73+
tmp, err := os.CreateTemp(dir, key+".tmp-*")
74+
if err != nil {
75+
return
76+
}
77+
defer os.Remove(tmp.Name())
78+
79+
if _, err := tmp.WriteString(strings.Join(suggestions, "\n")); err != nil {
80+
tmp.Close()
81+
return
82+
}
83+
if err := tmp.Close(); err != nil {
84+
return
85+
}
86+
87+
_ = os.Rename(tmp.Name(), filepath.Join(dir, key))
88+
}

internal/completion/cache_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// SPDX-FileCopyrightText: 2025 OVH SAS <opensource@ovh.net>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package completion
6+
7+
import (
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestCache_WriteThenRead(t *testing.T) {
15+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
16+
17+
want := []string{"proj-1\tProd", "proj-2\tPreprod"}
18+
writeCachedSuggestions("cloud-projects", want)
19+
20+
got, ok := readCachedSuggestions("cloud-projects")
21+
if !ok {
22+
t.Fatal("expected cache hit right after write")
23+
}
24+
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
25+
t.Errorf("got %q, want %q", got, want)
26+
}
27+
}
28+
29+
func TestCache_MissWhenAbsent(t *testing.T) {
30+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
31+
32+
if _, ok := readCachedSuggestions("cloud-projects"); ok {
33+
t.Error("expected a cache miss when no cache file exists")
34+
}
35+
}
36+
37+
func TestCache_ExpiredByTTL(t *testing.T) {
38+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
39+
40+
writeCachedSuggestions("cloud-projects", []string{"proj-1"})
41+
42+
// Make the cache file older than the TTL.
43+
path := filepath.Join(completionCacheDir(), "cloud-projects")
44+
old := time.Now().Add(-completionCacheTTL - time.Minute)
45+
if err := os.Chtimes(path, old, old); err != nil {
46+
t.Fatalf("failed to age cache file: %v", err)
47+
}
48+
49+
if _, ok := readCachedSuggestions("cloud-projects"); ok {
50+
t.Error("expected a cache miss for an expired cache file")
51+
}
52+
}
53+
54+
func TestCache_EmptyResultIsCached(t *testing.T) {
55+
t.Setenv("XDG_CACHE_HOME", t.TempDir())
56+
57+
writeCachedSuggestions("cloud-projects", []string{})
58+
59+
got, ok := readCachedSuggestions("cloud-projects")
60+
if !ok {
61+
t.Fatal("expected a cache hit for an empty (but valid) cached result")
62+
}
63+
if len(got) != 0 {
64+
t.Errorf("expected no suggestions, got %q", got)
65+
}
66+
}

internal/completion/completion.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ func CloudResourceWithChild(parentTemplate, childTemplate string) func(*cobra.Co
136136
// CloudProjects returns completion suggestions for the --cloud-project flag.
137137
// Each suggestion is "projectID\tName" so shells can display the project name alongside.
138138
func CloudProjects(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
139+
const cacheKey = "cloud-projects"
140+
141+
// Completion runs on every <tab>: serve from the on-disk cache when it is
142+
// still fresh to avoid an API call each time.
143+
if cached, ok := readCachedSuggestions(cacheKey); ok {
144+
return cached, cobra.ShellCompDirectiveNoFileComp
145+
}
146+
139147
if httpLib.Client == nil {
140148
httpLib.InitClient()
141149
}
@@ -153,13 +161,15 @@ func CloudProjects(_ *cobra.Command, _ []string, _ string) ([]string, cobra.Shel
153161
return nil, cobra.ShellCompDirectiveNoFileComp
154162
}
155163

156-
var suggestions []string
164+
suggestions := make([]string, 0, len(projects))
157165
for _, project := range projects {
158166
if project.Name != "" {
159167
suggestions = append(suggestions, project.ID+"\t"+project.Name)
160168
} else {
161169
suggestions = append(suggestions, project.ID)
162170
}
163171
}
172+
173+
writeCachedSuggestions(cacheKey, suggestions)
164174
return suggestions, cobra.ShellCompDirectiveNoFileComp
165175
}

0 commit comments

Comments
 (0)