package main import ( "errors" "fmt" "os" "path/filepath" "strings" ) // repoPath returns the absolute path to the sigil repository. // It uses SIGIL_REPO environment variable if set, otherwise defaults to ~/.dotfiles. func repoPath() (string, error) { if override := os.Getenv("SIGIL_REPO"); override != "" { return filepath.Abs(expandHome(override)) } return filepath.Abs(expandHome("~/.dotfiles")) } // expandHome expands a leading ~ to the user's home directory. // Returns the original path unchanged if expansion fails or path doesn't start with ~. func expandHome(path string) string { if path == "~" { home, err := os.UserHomeDir() if err != nil { return path } return home } if strings.HasPrefix(path, "~/") { home, err := os.UserHomeDir() if err != nil { return path } return filepath.Join(home, path[2:]) } return path } // compressHome compresses the user's home directory path to ~. // Returns the original path if it doesn't start with the home directory. func compressHome(path string) string { home, err := os.UserHomeDir() if err != nil { return path } clean := filepath.Clean(path) homeClean := filepath.Clean(home) if clean == homeClean { return "~" } if strings.HasPrefix(clean, homeClean+string(os.PathSeparator)) { rel := strings.TrimPrefix(clean, homeClean+string(os.PathSeparator)) return filepath.Join("~", rel) } return path } // splitPackageSpec splits a package spec like "pkg:path/to/file" into (pkg, path). // If no colon is present, returns (spec, "", nil). func splitPackageSpec(spec string) (string, string, error) { if spec == "" { return "", "", errors.New("missing package") } parts := strings.SplitN(spec, ":", 2) pkg := parts[0] rel := "" if len(parts) == 2 { rel = parts[1] } pkg = strings.Trim(pkg, "/") rel = strings.TrimPrefix(rel, "/") if pkg == "" { return "", "", errors.New("invalid package") } return pkg, rel, nil } // resolvePackageSpec resolves a package spec (name, path, or repo path) to (package, relative path). // It handles specs like "pkg:path", absolute paths within the repo, and absolute paths in the target. func resolvePackageSpec(spec string) (string, string, error) { repo, err := repoPath() if err != nil { return "", "", err } repoAbs, err := filepath.Abs(repo) if err != nil { return "", "", err } spec = expandHome(spec) if filepath.IsAbs(spec) { return resolvePathSpec(spec, repoAbs) } if strings.Contains(spec, string(os.PathSeparator)) { return resolvePathSpec(spec, repoAbs) } clean := filepath.Clean(spec) if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, string(os.PathSeparator)) { return resolvePathSpec(clean, repoAbs) } return splitPackageSpec(spec) } // resolvePathSpec resolves an absolute path spec to a package and relative path. // It checks if the path is within the repo or matches a package's target directory. func resolvePathSpec(pathSpec, repoAbs string) (string, string, error) { absPath, err := filepath.Abs(pathSpec) if err != nil { return "", "", err } if strings.HasPrefix(absPath, repoAbs+string(os.PathSeparator)) || absPath == repoAbs { rel, err := filepath.Rel(repoAbs, absPath) if err != nil { return "", "", err } parts := strings.Split(rel, string(os.PathSeparator)) if len(parts) >= 1 { pkg := parts[0] if len(parts) >= 2 && parts[1] == filesDirName { relPath := filepath.Join(parts[2:]...) return pkg, relPath, nil } return pkg, filepath.Join(parts[1:]...), nil } } entries, err := os.ReadDir(repoAbs) if err != nil { return "", "", err } for _, entry := range entries { if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { continue } pkgDir := filepath.Join(repoAbs, entry.Name()) configPath := filepath.Join(pkgDir, configFileName) cfg, err := loadConfig(configPath) if err != nil { continue } targetRoot, err := selectTarget(cfg) if err != nil { if errors.Is(err, errTargetDisabled) { continue } continue } absTarget, err := filepath.Abs(expandHome(targetRoot)) if err != nil { continue } if strings.HasPrefix(absPath, absTarget+string(os.PathSeparator)) || absPath == absTarget { rel, err := filepath.Rel(absTarget, absPath) if err != nil { return "", "", err } return entry.Name(), rel, nil } } return "", "", fmt.Errorf("could not resolve %s to a package", pathSpec) } // findPackageByTarget finds a package name that has the given target root. // Returns empty string if no package matches. func findPackageByTarget(repo, targetRoot string) (string, error) { repoEntries, err := os.ReadDir(repo) if err != nil { return "", err } absTarget, err := filepath.Abs(expandHome(targetRoot)) if err != nil { return "", err } for _, entry := range repoEntries { if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { continue } pkgDir := filepath.Join(repo, entry.Name()) configPath := filepath.Join(pkgDir, configFileName) cfg, err := loadConfig(configPath) if err != nil { continue } target, err := selectTarget(cfg) if err != nil { if errors.Is(err, errTargetDisabled) { continue } continue } absPkgTarget, err := filepath.Abs(expandHome(target)) if err != nil { continue } if absPkgTarget == absTarget { return entry.Name(), nil } } return "", nil } // repoPathForTarget returns the repo file path that a target symlink points to. // Returns empty string if the target is not a symlink to the repo. func repoPathForTarget(targetPath, repo string) (string, error) { info, err := os.Lstat(targetPath) if err != nil { return "", err } if info.Mode()&os.ModeSymlink == 0 { return "", nil } src, err := os.Readlink(targetPath) if err != nil { return "", err } if !filepath.IsAbs(src) { src = filepath.Join(filepath.Dir(targetPath), src) } src = filepath.Clean(src) repoAbs, err := filepath.Abs(repo) if err != nil { return "", err } rel, err := filepath.Rel(repoAbs, src) if err != nil { return "", err } if strings.HasPrefix(rel, "..") { return "", nil } parts := strings.Split(rel, string(os.PathSeparator)) if len(parts) < 3 { return "", nil } if parts[1] != filesDirName { return "", nil } return filepath.Join(repoAbs, rel), nil }