Add ID type

This commit is contained in:
Theodor S. Midtlien
2026-06-08 14:50:26 +02:00
parent c6f003fd18
commit f98fe1e9ec
14 changed files with 102 additions and 85 deletions
+15 -8
View File
@@ -31,18 +31,21 @@ const (
maxProfileIDLen = 64
)
type ID string
// generateProfileID returns a new random hex ID for a profile file.
func generateProfileID() (string, error) {
func generateProfileID() (ID, error) {
buf := make([]byte, profileIDByteLen)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("read random bytes: %w", err)
}
return hex.EncodeToString(buf), nil
return ID(hex.EncodeToString(buf)), nil
}
// IsValidProfileFilenameStem reports whether s is safe to use as the stem
// IsValidProfileFilenameStem reports whether id is safe to use as the stem
// of a profile JSON filename.
func IsValidProfileFilenameStem(s string) bool {
func IsValidProfileFilenameStem(id ID) bool {
s := id.String()
if s == "" || len(s) > maxProfileIDLen {
return false
}
@@ -99,12 +102,16 @@ func StripCtrlChars(name string) string {
}
// ShortID truncates an ID for display.
func ShortID(id string) string {
func (id ID) ShortID() string {
if id == DefaultProfileName {
return id
return DefaultProfileName
}
if len(id) <= shortIDLen {
return id
return id.String()
}
return id[:shortIDLen]
return id.String()[:shortIDLen]
}
func (id ID) String() string {
return string(id)
}
@@ -23,7 +23,7 @@ type Profile struct {
// it is a 32-char hex string; legacy profiles created before the
// ID-keyed layout keep their original name as their ID. The reserved
// value "default" identifies the special default profile.
ID string
ID ID
// Name is the human-readable display name. Falls back to ID when the
// underlying JSON has no "name" field set.
Name string
@@ -40,7 +40,7 @@ func (p *Profile) FilePath() (string, error) {
id := p.ID
if id == "" {
id = p.Name
id = ID(p.Name)
}
if id == "" {
return "", fmt.Errorf("profile ID is empty")
@@ -64,7 +64,7 @@ func (p *Profile) FilePath() (string, error) {
return "", fmt.Errorf("failed to get config directory for user %s: %w", username.Username, err)
}
return filepath.Join(configDir, id+".json"), nil
return filepath.Join(configDir, id.String()+".json"), nil
}
func (p *Profile) IsDefault() bool {
@@ -94,7 +94,7 @@ func (pm *ProfileManager) GetActiveProfile() (*Profile, error) {
// SwitchProfile records the given profile ID as active in the local user
// state file.
func (pm *ProfileManager) SwitchProfile(id string) error {
func (pm *ProfileManager) SwitchProfile(id ID) error {
if id != defaultProfileName && !IsValidProfileFilenameStem(id) {
return fmt.Errorf("invalid profile ID: %q", id)
}
@@ -116,7 +116,7 @@ func sanitizeProfileName(name string) string {
}, name)
}
func (pm *ProfileManager) getActiveProfileState() string {
func (pm *ProfileManager) getActiveProfileState() ID {
configDir, err := getConfigDir()
if err != nil {
@@ -144,10 +144,10 @@ func (pm *ProfileManager) getActiveProfileState() string {
return defaultProfileName
}
return profileName
return ID(profileName)
}
func (pm *ProfileManager) setActiveProfileState(profileName string) error {
func (pm *ProfileManager) setActiveProfileState(id ID) error {
configDir, err := getConfigDir()
if err != nil {
@@ -156,7 +156,7 @@ func (pm *ProfileManager) setActiveProfileState(profileName string) error {
statePath := filepath.Join(configDir, activeProfileStateFilename)
err = os.WriteFile(statePath, []byte(profileName), 0600)
err = os.WriteFile(statePath, []byte(id), 0600)
if err != nil {
return fmt.Errorf("failed to write active profile state: %w", err)
}
+13 -13
View File
@@ -91,7 +91,7 @@ type ActiveProfileState struct {
// before the ID-based config files. Legacy values were profile names, which
// were also the legacy filename stems, so they still resolve to the correct
// file on disk.
ID string `json:"name"`
ID ID `json:"name"`
Username string `json:"username"`
}
@@ -113,7 +113,7 @@ func (a *ActiveProfileState) FilePath() (string, error) {
return "", fmt.Errorf("failed to get config directory for user %s: %w", a.Username, err)
}
return filepath.Join(configDir, a.ID+".json"), nil
return filepath.Join(configDir, a.ID.String()+".json"), nil
}
type ServiceManager struct {
@@ -315,7 +315,7 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
return nil, fmt.Errorf("generate profile id: %w", err)
}
profPath := filepath.Join(configDir, id+".json")
profPath := filepath.Join(configDir, id.String()+".json")
cfg, err := createNewConfig(ConfigInput{ConfigPath: profPath})
if err != nil {
return nil, fmt.Errorf("failed to create new config: %w", err)
@@ -336,7 +336,7 @@ func (s *ServiceManager) AddProfile(displayName, username string) (*Profile, err
// RemoveProfile deletes the profile identified by id. Callers must have
// already resolved any user-supplied handle to a concrete ID via
// ResolveProfile.
func (s *ServiceManager) RemoveProfile(id, username string) error {
func (s *ServiceManager) RemoveProfile(id ID, username string) error {
if id == defaultProfileName {
return fmt.Errorf("cannot remove profile with reserved name: %s", defaultProfileName)
}
@@ -372,7 +372,7 @@ func (s *ServiceManager) RemoveProfile(id, username string) error {
return fmt.Errorf("failed to remove profile config: %w", err)
}
stateFile := filepath.Join(filepath.Dir(target.Path), id+".state.json")
stateFile := filepath.Join(filepath.Dir(target.Path), id.String()+".state.json")
if err := os.Remove(stateFile); err != nil && !os.IsNotExist(err) {
log.Warnf("failed to remove profile state file %s: %v", stateFile, err)
}
@@ -416,7 +416,7 @@ func (s *ServiceManager) GetStatePath() string {
return defaultStatePath
}
return filepath.Join(configDir, activeProf.ID+".state.json")
return filepath.Join(configDir, activeProf.ID.String()+".state.json")
}
// getConfigDir returns the profiles directory, using profilesDir if set, otherwise getConfigDirForUser
@@ -470,24 +470,24 @@ func (s *ServiceManager) loadAllProfiles(username string) ([]Profile, error) {
if strings.HasSuffix(base, ".state.json") {
continue
}
stem := strings.TrimSuffix(base, ".json")
stem := ID(strings.TrimSuffix(base, ".json"))
if stem == defaultProfileName {
// default lives at the top-level config dir, not under /<user>
continue
}
if !IsValidProfileFilenameStem(stem) {
if !IsValidProfileFilenameStem(ID(stem)) {
continue
}
path := filepath.Join(configDir, base)
name := readProfileName(path)
if name == "" {
name = stem
name = stem.String()
}
fileProfiles = append(fileProfiles, Profile{
ID: stem,
Name: name,
Path: path,
IsActive: stem == activeID,
IsActive: stem == ID(activeID),
})
}
@@ -517,7 +517,7 @@ func readProfileName(path string) string {
// activeProfileID returns the currently-active profile's ID. The second
// return value is true when the active profile is the default one.
func (s *ServiceManager) activeProfileID() (string, bool) {
func (s *ServiceManager) activeProfileID() (ID, bool) {
state, err := s.GetActiveProfileState()
if err != nil || state == nil {
return defaultProfileName, true
@@ -543,7 +543,7 @@ func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, erro
}
for i := range profiles {
if profiles[i].ID == handle {
if profiles[i].ID == ID(handle) {
return &profiles[i], nil
}
}
@@ -555,7 +555,7 @@ func (s *ServiceManager) ResolveProfile(handle, username string) (*Profile, erro
if profiles[i].ID == defaultProfileName {
continue
}
if strings.HasPrefix(profiles[i].ID, handle) {
if strings.HasPrefix(profiles[i].ID.String(), handle) {
prefixMatches = append(prefixMatches, profiles[i])
}
}
@@ -35,7 +35,7 @@ func TestServiceProfile_ExactID(t *testing.T) {
created, err := sm.AddProfile("work", username)
require.NoError(t, err)
got, err := sm.ResolveProfile(created.ID, username)
got, err := sm.ResolveProfile(created.ID.String(), username)
require.NoError(t, err)
assert.Equal(t, created.ID, got.ID)
assert.Equal(t, "work", got.Name)
@@ -48,7 +48,7 @@ func TestServiceProfile_IDPrefix(t *testing.T) {
require.NoError(t, err)
prefix := created.ID[:4]
got, err := sm.ResolveProfile(prefix, username)
got, err := sm.ResolveProfile(prefix.String(), username)
require.NoError(t, err)
assert.Equal(t, created.ID, got.ID)
})
@@ -208,7 +208,7 @@ func TestIsValidProfileFilenameStem(t *testing.T) {
{strings.Repeat("a", maxProfileIDLen+1), false},
}
for _, tc := range cases {
got := IsValidProfileFilenameStem(tc.in)
got := IsValidProfileFilenameStem(ID(tc.in))
assert.Equal(t, tc.want, got, "case %q", tc.in)
}
}
@@ -220,7 +220,7 @@ func TestRemoveProfile_DeletesStateFile(t *testing.T) {
configDir, err := sm.getConfigDir(username)
require.NoError(t, err)
statePath := filepath.Join(configDir, created.ID+".state.json")
statePath := filepath.Join(configDir, created.ID.String()+".state.json")
require.NoError(t, os.WriteFile(statePath, []byte(`{"email":"a@b"}`), 0600))
require.NoError(t, sm.RemoveProfile(created.ID, username))
+3 -3
View File
@@ -16,7 +16,7 @@ type ProfileState struct {
// GetProfileState reads the per-profile state file keyed by profile ID.
// The state file lives in the user's config directory. Legacy state files
// keyed by the old profile name remain readable.
func (pm *ProfileManager) GetProfileState(id string) (*ProfileState, error) {
func (pm *ProfileManager) GetProfileState(id ID) (*ProfileState, error) {
configDir, err := getConfigDir()
if err != nil {
return nil, fmt.Errorf("get config directory: %w", err)
@@ -26,7 +26,7 @@ func (pm *ProfileManager) GetProfileState(id string) (*ProfileState, error) {
return nil, fmt.Errorf("invalid profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id+".state.json")
stateFile := filepath.Join(configDir, id.String()+".state.json")
stateFileExists, err := fileExists(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to check if profile state file exists: %w", err)
@@ -63,7 +63,7 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
return fmt.Errorf("invalid active profile ID: %q", id)
}
stateFile := filepath.Join(configDir, id+".state.json")
stateFile := filepath.Join(configDir, id.String()+".state.json")
err = util.WriteJsonWithRestrictedPermission(context.Background(), stateFile, state)
if err != nil {
return fmt.Errorf("write profile state: %w", err)