From 3b43e5dbd2cada9e0645d6d70dc1c3a0947c7518 Mon Sep 17 00:00:00 2001 From: Aaron Martell Date: Wed, 19 Aug 2026 17:18:55 -0500 Subject: [PATCH] feat: extract and abstract home-relative path behind interface --- internal/scope/scope.go | 50 ++++++++++++++++++++++++++++++++++++ service/add.go | 2 +- service/doctor.go | 10 +++----- service/move.go | 2 +- service/service.go | 57 +++++++++++++++++++++++++++++------------ 5 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 internal/scope/scope.go diff --git a/internal/scope/scope.go b/internal/scope/scope.go new file mode 100644 index 0000000..46e12d8 --- /dev/null +++ b/internal/scope/scope.go @@ -0,0 +1,50 @@ +// Package scope abstracts the bidirectional path translation between a file's +// "live" location on the local filesystem and its "storage" location within +// the lnk repository. +package scope + +import ( + "fmt" + "path/filepath" +) + +// Resolver translates between absolute live paths and storage-relative paths. +type Resolver interface { + // ToStorage translates an absolute filesystem path into a path relative + // to the resolver's base directory. + ToStorage(absPath string) (string, error) + + // ToLive translates a storage-relative path back into an absolute + // filesystem path. + ToLive(storagePath string) (string, error) + + // BaseDir returns the directory within the lnk repo where files for + // this scope are stored. + BaseDir() string +} + +// HomeRelativeResolver maps paths relative to the user's home directory. +// This is the resolver for common and host scopes. +type HomeRelativeResolver struct { + Home string + StorageDir string +} + +// ToStorage returns the path of absPath relative to the home directory. +func (r *HomeRelativeResolver) ToStorage(absPath string) (string, error) { + rel, err := filepath.Rel(r.Home, absPath) + if err != nil { + return "", fmt.Errorf("make home-relative: %w", err) + } + return rel, nil +} + +// ToLive returns the absolute path for a storage-relative path. +func (r *HomeRelativeResolver) ToLive(storagePath string) (string, error) { + return filepath.Join(r.Home, storagePath), nil +} + +// BaseDir returns the configured storage directory for this scope. +func (r *HomeRelativeResolver) BaseDir() string { + return r.StorageDir +} diff --git a/service/add.go b/service/add.go index b52bbb5..4fe5f87 100644 --- a/service/add.go +++ b/service/add.go @@ -24,7 +24,7 @@ func (s *Service) Add(ctx context.Context, host string, paths []string) error { host = NormalizeHost(host) seen := make(map[string]struct{}, len(paths)) for _, input := range paths { - file, err := homeRelativePath(input) + file, err := s.homeRelativePath(input) if err != nil { return err } diff --git a/service/doctor.go b/service/doctor.go index 135afdb..6a4045f 100644 --- a/service/doctor.go +++ b/service/doctor.go @@ -258,11 +258,6 @@ func (s *Service) doctorFix(ctx context.Context, host string, all, pruneEmpty bo // scanCrossScope finds tracked paths that are inside a git repo. These belong // in project scope, not common/host scope. func (s *Service) scanCrossScope(ctx context.Context) ([]string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("resolve home directory: %w", err) - } - format, err := s.getFormat() if err != nil { return nil, err @@ -280,7 +275,10 @@ func (s *Service) scanCrossScope(ctx context.Context) ([]string, error) { return nil, itemErr } for _, item := range items { - absPath := filepath.Join(homeDir, item) + absPath, err := s.resolver.ToLive(item) + if err != nil { + return nil, err + } if _, statErr := os.Stat(absPath); statErr != nil { if errors.Is(statErr, os.ErrNotExist) { continue diff --git a/service/move.go b/service/move.go index 4a47b6b..8b3d030 100644 --- a/service/move.go +++ b/service/move.go @@ -16,7 +16,7 @@ func (s *Service) Move(ctx context.Context, input string, toHost string, toCommo if err := s.requireGitRepo(); err != nil { return err } - file, err := homeRelativePath(input) + file, err := s.homeRelativePath(input) if err != nil { return err } diff --git a/service/service.go b/service/service.go index 4296e70..f227fda 100644 --- a/service/service.go +++ b/service/service.go @@ -15,6 +15,7 @@ import ( fspkg "github.com/polymorcodeus/lnk/internal/fs" gitpkg "github.com/polymorcodeus/lnk/internal/git" "github.com/polymorcodeus/lnk/internal/lnkerror" + "github.com/polymorcodeus/lnk/internal/scope" "github.com/polymorcodeus/lnk/internal/tracker" ) @@ -31,10 +32,20 @@ type Service struct { git *gitpkg.Git format tracker.RepoFormat gitConfigured bool + resolver scope.Resolver } type Option func(*Service) +// WithResolver sets the scope resolver used to translate between live and +// storage paths. When unset, New constructs a HomeRelativeResolver from +// os.UserHomeDir(). +func WithResolver(r scope.Resolver) Option { + return func(s *Service) { + s.resolver = r + } +} + // WithColor is a convenience wrapper that returns git.WithColor func WithColor(enabled bool) Option { if enabled { @@ -101,9 +112,29 @@ func New(repoPath string, opts ...Option) *Service { opt(s) } + if s.resolver == nil { + homeDir, err := os.UserHomeDir() + if err != nil { + // Preserve existing error behavior by using a resolver that + // surfaces the home-directory lookup error when used. + s.resolver = &failingResolver{err: fmt.Errorf("resolve home directory: %w", err)} + } else { + s.resolver = &scope.HomeRelativeResolver{Home: homeDir} + } + } + return s } +// failingResolver returns its construction error for every operation. +type failingResolver struct { + err error +} + +func (r *failingResolver) ToStorage(string) (string, error) { return "", r.err } +func (r *failingResolver) ToLive(string) (string, error) { return "", r.err } +func (r *failingResolver) BaseDir() string { return "" } + // ResolveRepoPath resolves the repo path from explicit flag or environment. func ResolveRepoPath(explicit string) string { if explicit != "" { @@ -223,7 +254,7 @@ func (s *Service) findOwnerInScope(relativePath, host string) (*owner, error) { // resolveRemovalScope determines which scope owns a path for removal/forget operations. func (s *Service) resolveRemovalScope(input, host string) (string, homePath, error) { // host is explicitly not normalized to allow for input lookup across all scopes - file, err := homeRelativePath(input) + file, err := s.homeRelativePath(input) if err != nil { return "", homePath{}, err } @@ -286,11 +317,10 @@ func (s *Service) scanCollisions() ([]OwnershipCollision, error) { // repointManagedSymlink recreates the live symlink so it points to the new storage path. func (s *Service) repointManagedSymlink(relativePath, targetPath string) error { - homeDir, err := os.UserHomeDir() + livePath, err := s.resolver.ToLive(relativePath) if err != nil { - return fmt.Errorf("resolve home directory: %w", err) + return err } - livePath := filepath.Join(homeDir, relativePath) if _, err := os.Lstat(livePath); errors.Is(err, os.ErrNotExist) { return nil } @@ -324,16 +354,12 @@ func (s *Service) stagePaths(ctx context.Context, paths ...string) error { } // homeRelativePath resolves an input path to an absolute path and its relative path from $HOME. -func homeRelativePath(input string) (homePath, error) { +func (s *Service) homeRelativePath(input string) (homePath, error) { absPath, err := filepath.Abs(input) if err != nil { return homePath{}, fmt.Errorf("resolve path %s: %w", input, err) } - homeDir, err := os.UserHomeDir() - if err != nil { - return homePath{}, fmt.Errorf("resolve home directory: %w", err) - } - relativePath, err := filepath.Rel(homeDir, absPath) + relativePath, err := s.resolver.ToStorage(absPath) if err != nil { return homePath{}, fmt.Errorf("resolve relative path %s: %w", input, err) } @@ -377,11 +403,6 @@ func isManagedSymlink(livePath, expectedTarget string) bool { // profileItems returns the ordered list of items that make up the effective machine profile. func (s *Service) profileItems(host string) ([]profileItem, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return nil, fmt.Errorf("resolve home directory: %w", err) - } - format, err := s.getFormat() if err != nil { return nil, err @@ -410,10 +431,14 @@ func (s *Service) profileItems(host string) ([]profileItem, error) { if err != nil { return nil, err } + livePath, err := s.resolver.ToLive(relativePath) + if err != nil { + return nil, err + } items = append(items, profileItem{ RelativePath: relativePath, RepoPath: filepath.Join(hostPath, relativePath), - LivePath: filepath.Join(homeDir, relativePath), + LivePath: livePath, }) } }