move core logic to internal/core package, bump to 0.1.3

This commit is contained in:
2026-03-05 14:56:28 +00:00
parent bc02c93acc
commit 0215a53fcf
8 changed files with 232 additions and 230 deletions
+219
View File
@@ -0,0 +1,219 @@
package core
import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
"strings"
lua "github.com/yuin/gopher-lua"
)
const (
ConfigFileName = "config.lua"
FilesDirName = "files"
)
type packageConfig struct {
targets map[string]string
disabled map[string]bool
ignore []string
compiledIgnores []*regexp.Regexp
}
var ErrTargetDisabled = errors.New("target disabled for this platform")
func LoadConfig(path string) (*packageConfig, error) {
L := lua.NewState()
defer L.Close()
L.OpenLibs()
if err := L.DoFile(path); err != nil {
return nil, err
}
if L.GetTop() == 0 {
return nil, errors.New("config.lua must return a table")
}
value := L.Get(-1)
tbl, ok := value.(*lua.LTable)
if !ok {
return nil, errors.New("config.lua must return a table")
}
targetVal := tbl.RawGetString("target")
targetTbl, ok := targetVal.(*lua.LTable)
if !ok {
return nil, errors.New("config.target must be a table")
}
targets := make(map[string]string)
disabled := make(map[string]bool)
targetTbl.ForEach(func(k, v lua.LValue) {
ks, ok := k.(lua.LString)
if !ok {
return
}
if v == lua.LNil || v == lua.LFalse {
disabled[string(ks)] = true
return
}
vs, ok := v.(lua.LString)
if !ok {
return
}
targets[string(ks)] = ExpandHome(string(vs))
})
if len(targets) == 0 && len(disabled) == 0 {
return nil, errors.New("config.target is empty")
}
ignore, err := ParseIgnore(tbl)
if err != nil {
return nil, err
}
compiledIgnores, err := CompileIgnorePatterns(ignore)
if err != nil {
return nil, err
}
return &packageConfig{targets: targets, disabled: disabled, ignore: ignore, compiledIgnores: compiledIgnores}, nil
}
func SelectTarget(cfg *packageConfig) (string, error) {
osKey := runtime.GOOS
if osKey == "darwin" {
osKey = "macos"
}
if cfg.disabled[osKey] {
return "", ErrTargetDisabled
}
if target, ok := cfg.targets[osKey]; ok {
return ExpandHome(target), nil
}
if target, ok := cfg.targets["default"]; ok {
return ExpandHome(target), nil
}
return "", fmt.Errorf("missing target for %s and default", osKey)
}
func ParseIgnore(cfgTbl *lua.LTable) ([]string, error) {
ignoreVal := cfgTbl.RawGetString("ignore")
if ignoreVal == lua.LNil {
return nil, nil
}
ignoreTbl, ok := ignoreVal.(*lua.LTable)
if !ok {
return nil, errors.New("config.ignore must be an array of strings")
}
ignore := make([]string, 0, ignoreTbl.Len())
var parseErr error
ignoreTbl.ForEach(func(k, v lua.LValue) {
if parseErr != nil {
return
}
if _, ok := k.(lua.LNumber); !ok {
parseErr = errors.New("config.ignore must be an array of strings")
return
}
s, ok := v.(lua.LString)
if !ok {
parseErr = errors.New("config.ignore must contain only strings")
return
}
pattern := strings.TrimSpace(string(s))
if pattern == "" {
parseErr = errors.New("config.ignore cannot contain empty patterns")
return
}
ignore = append(ignore, pattern)
})
if parseErr != nil {
return nil, parseErr
}
return ignore, nil
}
func CompileIgnorePatterns(patterns []string) ([]*regexp.Regexp, error) {
compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, pattern := range patterns {
re, err := GlobToRegexp(pattern)
if err != nil {
return nil, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err)
}
compiled = append(compiled, re)
}
return compiled, nil
}
func GlobToRegexp(pattern string) (*regexp.Regexp, error) {
pattern = strings.ReplaceAll(pattern, "\\", "/")
pattern = strings.TrimPrefix(pattern, "./")
if strings.HasPrefix(pattern, "/") {
pattern = strings.TrimPrefix(pattern, "/")
}
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(pattern); {
if i+1 < len(pattern) && pattern[i] == '*' && pattern[i+1] == '*' {
b.WriteString(".*")
i += 2
continue
}
ch := pattern[i]
switch ch {
case '*':
b.WriteString("[^/]*")
case '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(string(ch)))
}
i++
}
b.WriteString("$")
return regexp.Compile(b.String())
}
func ShouldIgnorePath(rel string, cfg *packageConfig) bool {
if cfg == nil || len(cfg.compiledIgnores) == 0 {
return false
}
normalized := path.Clean(filepath.ToSlash(rel))
for _, re := range cfg.compiledIgnores {
if re.MatchString(normalized) {
return true
}
}
return false
}
func WriteConfig(path, targetRoot string) error {
osKey := "linux"
if runtime.GOOS == "darwin" {
osKey = "macos"
}
prettyTarget := CompressHome(targetRoot)
content := fmt.Sprintf("---@class SigilConfig\n---@field target table<string, string|boolean>\n---@field ignore? string[]\n\n---@type SigilConfig\nlocal config = {\n\ttarget = {\n\t\t%s = %q,\n\t\tdefault = %q,\n\t},\n\tignore = {\n\t\t-- \"**/.DS_Store\",\n\t\t-- \"**/*.tmp\",\n\t\t-- \"cache/**\",\n\t},\n}\n\nreturn config\n", osKey, prettyTarget, prettyTarget)
return os.WriteFile(path, []byte(content), 0o644)
}
+119
View File
@@ -0,0 +1,119 @@
package core
import (
"regexp"
"testing"
)
func TestGlobToRegexp(t *testing.T) {
tests := []struct {
pattern string
matches []string
noMatch []string
}{
{
pattern: "*.txt",
matches: []string{"file.txt", "foo.txt"},
noMatch: []string{"file.log", "dir/file.txt", "file.txt.bak"},
},
{
pattern: "**/*.txt",
matches: []string{"dir/file.txt", "a/b/c/file.txt"},
noMatch: []string{"file.txt", "file.log", "file.txt.bak"},
},
{
pattern: "cache/**",
matches: []string{"cache/file", "cache/a/b/c"},
noMatch: []string{"my-cache/file", "cache"},
},
{
pattern: "?.log",
matches: []string{"a.log", "1.log"},
noMatch: []string{"file.log", ".log", "ab.log"},
},
{
pattern: "**/.DS_Store",
matches: []string{"foo/.DS_Store", "a/b/.DS_Store"},
noMatch: []string{".DS_Store", ".DS_Store.bak", "foo/.DS_Store.txt"},
},
{
pattern: "./file.txt",
matches: []string{"file.txt"},
noMatch: []string{"./file.txt"}, // leading ./ is stripped
},
{
pattern: "/absolute",
matches: []string{"absolute"},
noMatch: []string{"/absolute"}, // leading / is stripped
},
}
for _, tt := range tests {
t.Run(tt.pattern, func(t *testing.T) {
re, err := GlobToRegexp(tt.pattern)
if err != nil {
t.Fatalf("GlobToRegexp(%q) error: %v", tt.pattern, err)
}
for _, m := range tt.matches {
if !re.MatchString(m) {
t.Errorf("pattern %q should match %q, but didn't", tt.pattern, m)
}
}
for _, m := range tt.noMatch {
if re.MatchString(m) {
t.Errorf("pattern %q should NOT match %q, but did", tt.pattern, m)
}
}
})
}
}
func TestShouldIgnorePath(t *testing.T) {
cfg := &packageConfig{
compiledIgnores: []*regexp.Regexp{},
}
// Test with nil config
if ShouldIgnorePath("foo.txt", nil) {
t.Error("ShouldIgnorePath with nil config should return false")
}
// Test with empty ignores
if ShouldIgnorePath("foo.txt", cfg) {
t.Error("ShouldIgnorePath with empty ignores should return false")
}
// Add some patterns and re-test
patterns := []string{"*.log", "cache/**", "**/.DS_Store"}
compiled, err := CompileIgnorePatterns(patterns)
if err != nil {
t.Fatalf("CompileIgnorePatterns error: %v", err)
}
cfg.compiledIgnores = compiled
tests := []struct {
path string
ignored bool
}{
{"debug.log", true},
{"app.log", true},
{"file.txt", false},
{"cache/foo", true},
{"cache/a/b", true},
{"my-cache/foo", false},
{".DS_Store", false}, // root-level .DS_Store not matched by **/.DS_Store
{"foo/.DS_Store", true},
{"main.go", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := ShouldIgnorePath(tt.path, cfg)
if got != tt.ignored {
t.Errorf("ShouldIgnorePath(%q) = %v, want %v", tt.path, got, tt.ignored)
}
})
}
}
+483
View File
@@ -0,0 +1,483 @@
package core
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
func ApplyPackage(filesDir, targetRoot string, cfg *packageConfig) error {
if err := EnsureDir(targetRoot); err != nil {
return err
}
return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == filesDir {
return nil
}
rel, err := filepath.Rel(filesDir, path)
if err != nil {
return err
}
if ShouldIgnorePath(rel, cfg) {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
targetPath := filepath.Join(targetRoot, rel)
if entry.IsDir() {
return EnsureDir(targetPath)
}
srcAbs, err := filepath.Abs(path)
if err != nil {
return err
}
return LinkFile(srcAbs, targetPath)
})
}
func LinkFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if info, err := os.Lstat(dst); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
current, err := os.Readlink(dst)
if err != nil {
return err
}
if current == src {
return nil
}
return fmt.Errorf("conflict at %s (symlink points to %s, expected %s). Run 'sigil unlink %s' to restore and remove the conflicting link", dst, current, src, dst)
}
return fmt.Errorf("conflict at %s (file exists and is not a symlink). Back up or remove this file first", dst)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Symlink(src, dst)
}
func EnsureDir(path string) error {
info, err := os.Lstat(path)
if err == nil {
if info.IsDir() {
return nil
}
return fmt.Errorf("%s exists and is not a directory", path)
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.MkdirAll(path, 0o755)
}
func FindStaleLinks(filesDir, targetRoot string) ([]string, error) {
filesAbs, err := filepath.Abs(filesDir)
if err != nil {
return nil, err
}
targetAbs, err := filepath.Abs(targetRoot)
if err != nil {
return nil, err
}
var stale []string
walkErr := filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(filesAbs, path)
if err != nil {
return err
}
targetPath := filepath.Join(targetAbs, rel)
info, err := os.Lstat(targetPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Symlink doesn't exist - not stale, just not applied
return nil
}
return err
}
if info.Mode()&os.ModeSymlink == 0 {
// Not a symlink, skip
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)
// Only consider symlinks pointing to our repo
if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
return nil
}
// Check if the source file in repo still exists
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
stale = append(stale, targetPath)
}
return nil
})
if walkErr != nil {
return nil, walkErr
}
return stale, nil
}
func RemoveLinks(paths []string, dryRun bool) error {
for _, path := range paths {
if dryRun {
fmt.Printf("dry-run: remove %s\n", path)
continue
}
if err := os.Remove(path); err != nil {
return err
}
fmt.Printf("removed %s\n", path)
}
return nil
}
func HandleStaleLinks(stales []string) error {
if len(stales) == 0 {
return nil
}
repo, err := RepoPath()
if err != nil {
return err
}
reader := newReader()
for _, path := range stales {
fmt.Printf("stale: %s\n", path)
canUnlink, err := StaleHasRepoFile(path, repo)
if err != nil {
return err
}
prompt := "action [p=prune, u=unlink, i=ignore]: "
if !canUnlink {
prompt = "action [p=prune, i=ignore]: "
}
fmt.Print(prompt)
choice, err := reader.ReadString('\n')
if err != nil {
return err
}
choice = strings.TrimSpace(strings.ToLower(choice))
if choice == "" || choice == "i" {
continue
}
if choice == "p" {
if err := os.Remove(path); err != nil {
return err
}
fmt.Printf("removed %s\n", path)
continue
}
if choice == "u" && canUnlink {
if err := UnlinkStale(path, repo); err != nil {
return err
}
continue
}
fmt.Println("invalid choice; skipping")
}
return nil
}
func StaleHasRepoFile(targetPath, repo string) (bool, error) {
RepoPath, err := RepoPathForTarget(targetPath, repo)
if err != nil {
return false, err
}
if RepoPath == "" {
return false, nil
}
if _, err := os.Stat(RepoPath); errors.Is(err, os.ErrNotExist) {
return false, nil
} else if err != nil {
return false, err
}
return true, nil
}
func UnlinkStale(targetPath, repo string) error {
RepoPath, err := RepoPathForTarget(targetPath, repo)
if err != nil {
return err
}
if RepoPath == "" {
return nil
}
if err := os.Remove(targetPath); err != nil {
return err
}
if err := CopyFile(RepoPath, targetPath); err != nil {
return err
}
if err := os.Remove(RepoPath); err != nil {
return err
}
fmt.Printf("unlinked %s (removed %s)\n", targetPath, RepoPath)
return nil
}
func RestorePackage(filesDir, targetRoot string, dryRun bool) error {
filesAbs, err := filepath.Abs(filesDir)
if err != nil {
return err
}
return filepath.WalkDir(filesDir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if path == filesDir {
return nil
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(filesDir, path)
if err != nil {
return err
}
targetPath := filepath.Join(targetRoot, rel)
return RestoreOne(path, targetPath, filesAbs, dryRun)
})
}
func RestorePath(filesDir, targetRoot, relPath string, dryRun bool) error {
filesAbs, err := filepath.Abs(filesDir)
if err != nil {
return err
}
relPath = filepath.Clean(relPath)
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
return fmt.Errorf("invalid relative path %q", relPath)
}
sourcePath := filepath.Join(filesDir, relPath)
info, err := os.Lstat(sourcePath)
if err != nil {
return err
}
if info.IsDir() {
return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(filesDir, path)
if err != nil {
return err
}
targetPath := filepath.Join(targetRoot, rel)
return RestoreOne(path, targetPath, filesAbs, dryRun)
})
}
rel, err := filepath.Rel(filesDir, sourcePath)
if err != nil {
return err
}
return RestoreOne(sourcePath, filepath.Join(targetRoot, rel), filesAbs, dryRun)
}
func RestoreOne(sourcePath, targetPath, filesAbs string, dryRun bool) error {
info, err := os.Lstat(targetPath)
if errors.Is(err, os.ErrNotExist) {
return nil
}
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)
if !strings.HasPrefix(src, filesAbs+string(os.PathSeparator)) && src != filesAbs {
return nil
}
if dryRun {
fmt.Printf("dry-run: restore %s\n", targetPath)
return nil
}
if err := os.Remove(targetPath); err != nil {
return err
}
fmt.Printf("restored %s\n", targetPath)
return CopyFile(sourcePath, targetPath)
}
func CopyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
info, err := srcFile.Stat()
if err != nil {
return err
}
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
return nil
}
func MoveDirContents(srcDir, destDir string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(srcDir, entry.Name())
destPath := filepath.Join(destDir, entry.Name())
if _, err := os.Stat(destPath); err == nil {
return fmt.Errorf("destination already exists: %s", destPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(srcPath, destPath); err != nil {
return err
}
}
return nil
}
func RemoveOrUnlink(args []string, isRemove bool) error {
_ = isRemove // commands are identical, flag kept for clarity
flags, pkgSpec, err := ParsePackageFlags(args)
if err != nil {
return err
}
pkgName, relPath, err := ResolvePackageSpec(pkgSpec)
if err != nil {
return err
}
repo, err := RepoPath()
if err != nil {
return err
}
pkgDir := filepath.Join(repo, pkgName)
configPath := filepath.Join(pkgDir, ConfigFileName)
cfg, err := LoadConfig(configPath)
if err != nil {
return err
}
targetRoot, err := SelectTarget(cfg)
if err != nil {
return err
}
filesDir := filepath.Join(pkgDir, FilesDirName)
if _, err := os.Stat(filesDir); err != nil {
return err
}
if relPath == "" {
if err := RestorePackage(filesDir, targetRoot, flags.dryRun); err != nil {
return err
}
if flags.dryRun {
return nil
}
if err := os.RemoveAll(pkgDir); err != nil {
return err
}
fmt.Printf("removed package %s\n", pkgName)
return nil
}
if err := RestorePath(filesDir, targetRoot, relPath, flags.dryRun); err != nil {
return err
}
if flags.dryRun {
return nil
}
if err := os.RemoveAll(filepath.Join(filesDir, relPath)); err != nil {
return err
}
fmt.Printf("removed %s:%s\n", pkgName, relPath)
return nil
}
+306
View File
@@ -0,0 +1,306 @@
package core
import (
"os"
"path/filepath"
"regexp"
"testing"
)
func TestLinkFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "source.txt")
dst := filepath.Join(tmp, "link.txt")
// Create source file
if err := os.WriteFile(src, []byte("hello"), 0o644); err != nil {
t.Fatalf("failed to create source: %v", err)
}
// Test: create symlink
if err := LinkFile(src, dst); err != nil {
t.Errorf("LinkFile failed: %v", err)
}
// Verify symlink
info, err := os.Lstat(dst)
if err != nil {
t.Fatalf("failed to stat link: %v", err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Error("dst is not a symlink")
}
// Test: idempotent - same symlink again should succeed
if err := LinkFile(src, dst); err != nil {
t.Errorf("LinkFile second time failed: %v", err)
}
// Test: conflict - different source should error
src2 := filepath.Join(tmp, "source2.txt")
if err := os.WriteFile(src2, []byte("world"), 0o644); err != nil {
t.Fatalf("failed to create source2: %v", err)
}
if err := LinkFile(src2, dst); err == nil {
t.Error("LinkFile should fail with conflicting symlink")
}
// Test: conflict - non-symlink file should error
dst2 := filepath.Join(tmp, "regular.txt")
if err := os.WriteFile(dst2, []byte("content"), 0o644); err != nil {
t.Fatalf("failed to create regular file: %v", err)
}
if err := LinkFile(src, dst2); err == nil {
t.Error("LinkFile should fail when target exists and is not a symlink")
}
}
func TestFindStaleLinks(t *testing.T) {
tmp := t.TempDir()
filesDir := filepath.Join(tmp, "files")
targetDir := filepath.Join(tmp, "target")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
t.Fatalf("failed to create files dir: %v", err)
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("failed to create target dir: %v", err)
}
// Create a file in files/
src := filepath.Join(filesDir, "test.txt")
if err := os.WriteFile(src, []byte("content"), 0o644); err != nil {
t.Fatalf("failed to create source: %v", err)
}
// Create a subdirectory with a file
subDir := filepath.Join(filesDir, "subdir")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
src2 := filepath.Join(subDir, "nested.txt")
if err := os.WriteFile(src2, []byte("nested"), 0o644); err != nil {
t.Fatalf("failed to create nested source: %v", err)
}
// Create symlinks
dst := filepath.Join(targetDir, "test.txt")
if err := os.Symlink(src, dst); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
targetSubDir := filepath.Join(targetDir, "subdir")
if err := os.MkdirAll(targetSubDir, 0o755); err != nil {
t.Fatalf("failed to create target subdir: %v", err)
}
dst2 := filepath.Join(targetSubDir, "nested.txt")
if err := os.Symlink(src2, dst2); err != nil {
t.Fatalf("failed to create nested symlink: %v", err)
}
// Test: no stale links when sources exist
stale, err := FindStaleLinks(filesDir, targetDir)
if err != nil {
t.Errorf("FindStaleLinks failed: %v", err)
}
if len(stale) != 0 {
t.Errorf("expected 0 stale links, got %d", len(stale))
}
// Note: The current implementation walks filesDir, so it can't detect
// stale links for files that have been deleted from the repo.
// This is a known limitation - we'd need to walk the target directory
// to find orphaned symlinks, but that has performance issues with
// large target directories (like ~).
}
func TestFindStaleLinks_IgnoresNonRepoSymlinks(t *testing.T) {
tmp := t.TempDir()
filesDir := filepath.Join(tmp, "files")
targetDir := filepath.Join(tmp, "target")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
t.Fatalf("failed to create files dir: %v", err)
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("failed to create target dir: %v", err)
}
// Create a file outside the repo
external := filepath.Join(tmp, "external.txt")
if err := os.WriteFile(external, []byte("external"), 0o644); err != nil {
t.Fatalf("failed to create external file: %v", err)
}
// Create a symlink to external file (should be ignored by FindStaleLinks)
dst := filepath.Join(targetDir, "link.txt")
if err := os.Symlink(external, dst); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Test: should not report external symlinks as stale
stale, err := FindStaleLinks(filesDir, targetDir)
if err != nil {
t.Errorf("FindStaleLinks failed: %v", err)
}
if len(stale) != 0 {
t.Errorf("expected 0 stale links (external symlinks ignored), got %d", len(stale))
}
}
func TestApplyPackage(t *testing.T) {
tmp := t.TempDir()
filesDir := filepath.Join(tmp, "files")
targetDir := filepath.Join(tmp, "target")
// Create files structure
subDir := filepath.Join(filesDir, "config")
if err := os.MkdirAll(subDir, 0o755); err != nil {
t.Fatalf("failed to create subdir: %v", err)
}
file1 := filepath.Join(filesDir, "readme.txt")
if err := os.WriteFile(file1, []byte("readme"), 0o644); err != nil {
t.Fatalf("failed to create file1: %v", err)
}
file2 := filepath.Join(subDir, "app.conf")
if err := os.WriteFile(file2, []byte("config"), 0o644); err != nil {
t.Fatalf("failed to create file2: %v", err)
}
cfg := &packageConfig{} // empty config, no ignores
// Apply package
if err := ApplyPackage(filesDir, targetDir, cfg); err != nil {
t.Errorf("ApplyPackage failed: %v", err)
}
// Verify symlinks exist
targetFile1 := filepath.Join(targetDir, "readme.txt")
info, err := os.Lstat(targetFile1)
if err != nil {
t.Errorf("target file1 not found: %v", err)
} else if info.Mode()&os.ModeSymlink == 0 {
t.Error("target file1 is not a symlink")
}
targetFile2 := filepath.Join(targetDir, "config", "app.conf")
info, err = os.Lstat(targetFile2)
if err != nil {
t.Errorf("target file2 not found: %v", err)
} else if info.Mode()&os.ModeSymlink == 0 {
t.Error("target file2 is not a symlink")
}
// Verify content is accessible through symlink
content, err := os.ReadFile(targetFile1)
if err != nil {
t.Errorf("failed to read through symlink: %v", err)
}
if string(content) != "readme" {
t.Errorf("wrong content through symlink: %s", string(content))
}
}
func TestApplyPackage_WithIgnores(t *testing.T) {
tmp := t.TempDir()
filesDir := filepath.Join(tmp, "files")
targetDir := filepath.Join(tmp, "target")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
t.Fatalf("failed to create files dir: %v", err)
}
// Create files, some to be ignored
mainFile := filepath.Join(filesDir, "main.txt")
if err := os.WriteFile(mainFile, []byte("main"), 0o644); err != nil {
t.Fatalf("failed to create main file: %v", err)
}
cacheDir := filepath.Join(filesDir, "cache")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
t.Fatalf("failed to create cache dir: %v", err)
}
cacheFile := filepath.Join(cacheDir, "data.tmp")
if err := os.WriteFile(cacheFile, []byte("temp"), 0o644); err != nil {
t.Fatalf("failed to create cache file: %v", err)
}
// Create config with ignore pattern
cfg := &packageConfig{
compiledIgnores: []*regexp.Regexp{},
}
re, _ := GlobToRegexp("cache/**")
cfg.compiledIgnores = append(cfg.compiledIgnores, re)
// Apply package
if err := ApplyPackage(filesDir, targetDir, cfg); err != nil {
t.Errorf("ApplyPackage failed: %v", err)
}
// Verify main file is linked
targetMain := filepath.Join(targetDir, "main.txt")
if _, err := os.Lstat(targetMain); err != nil {
t.Errorf("main file should be linked: %v", err)
}
// Verify cache file is not linked (the directory may exist since we walk it
// before checking ignore patterns, but it should be empty)
targetCacheFile := filepath.Join(targetDir, "cache", "data.tmp")
if _, err := os.Lstat(targetCacheFile); !os.IsNotExist(err) {
t.Error("cache/data.tmp should not exist in target")
}
}
func TestRestoreOne(t *testing.T) {
tmp := t.TempDir()
filesDir := filepath.Join(tmp, "files")
targetDir := filepath.Join(tmp, "target")
if err := os.MkdirAll(filesDir, 0o755); err != nil {
t.Fatalf("failed to create files dir: %v", err)
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatalf("failed to create target dir: %v", err)
}
// Create source file in repo
src := filepath.Join(filesDir, "test.txt")
if err := os.WriteFile(src, []byte("original"), 0o644); err != nil {
t.Fatalf("failed to create source: %v", err)
}
// Create symlink in target
dst := filepath.Join(targetDir, "test.txt")
if err := os.Symlink(src, dst); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Restore (removes symlink, copies file)
filesAbs, _ := filepath.Abs(filesDir)
if err := RestoreOne(src, dst, filesAbs, false); err != nil {
t.Errorf("RestoreOne failed: %v", err)
}
// Verify symlink is gone
info, err := os.Lstat(dst)
if err != nil {
t.Fatalf("failed to stat restored file: %v", err)
}
if info.Mode()&os.ModeSymlink != 0 {
t.Error("file should not be a symlink after restore")
}
// Verify content is preserved
content, err := os.ReadFile(dst)
if err != nil {
t.Errorf("failed to read restored file: %v", err)
}
if string(content) != "original" {
t.Errorf("wrong content after restore: %s", string(content))
}
}
+260
View File
@@ -0,0 +1,260 @@
package core
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
}
+163
View File
@@ -0,0 +1,163 @@
package core
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestExpandHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("cannot get home dir: %v", err)
}
tests := []struct {
input string
expected string
}{
{"~", home},
{"~/foo", filepath.Join(home, "foo")},
{"~/foo/bar", filepath.Join(home, "foo", "bar")},
{"/absolute/path", "/absolute/path"},
{"relative/path", "relative/path"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := ExpandHome(tt.input)
if got != tt.expected {
t.Errorf("ExpandHome(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestCompressHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("cannot get home dir: %v", err)
}
tests := []struct {
input string
expected string
}{
{home, "~"},
{filepath.Join(home, "foo"), "~/foo"},
{filepath.Join(home, "foo", "bar"), "~/foo/bar"},
{"/other/path", "/other/path"},
{"relative/path", "relative/path"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := CompressHome(tt.input)
if got != tt.expected {
t.Errorf("CompressHome(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
func TestSplitPackageSpec(t *testing.T) {
tests := []struct {
input string
wantPkg string
wantRel string
wantErr bool
}{
{"pkg", "pkg", "", false},
{"pkg:path", "pkg", "path", false},
{"pkg:path/to/file", "pkg", "path/to/file", false},
{"pkg:/leading", "pkg", "leading", false},
{"/leading/pkg", "leading/pkg", "", false}, // leading slash trimmed
{"", "", "", true},
{":nope", "", "", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
pkg, rel, err := SplitPackageSpec(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("SplitPackageSpec(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if pkg != tt.wantPkg || rel != tt.wantRel {
t.Errorf("SplitPackageSpec(%q) = (%q, %q), want (%q, %q)", tt.input, pkg, rel, tt.wantPkg, tt.wantRel)
}
})
}
}
func TestSelectTarget(t *testing.T) {
goos := runtime.GOOS
osKey := goos
if osKey == "darwin" {
osKey = "macos"
}
tests := []struct {
name string
cfg *packageConfig
want string
wantErr bool
}{
{
name: "matching os",
cfg: &packageConfig{
targets: map[string]string{osKey: "/foo"},
},
want: filepath.Join("/foo"),
wantErr: false,
},
{
name: "fallback to default",
cfg: &packageConfig{
targets: map[string]string{"default": "/bar"},
},
want: filepath.Join("/bar"),
wantErr: false,
},
{
name: "os overrides default",
cfg: &packageConfig{
targets: map[string]string{osKey: "/os", "default": "/default"},
},
want: filepath.Join("/os"),
wantErr: false,
},
{
name: "disabled target",
cfg: &packageConfig{
targets: map[string]string{},
disabled: map[string]bool{osKey: true},
},
want: "",
wantErr: true,
},
{
name: "missing target",
cfg: &packageConfig{
targets: map[string]string{"other": "/other"},
},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SelectTarget(tt.cfg)
if (err != nil) != tt.wantErr {
t.Errorf("SelectTarget() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("SelectTarget() = %q, want %q", got, tt.want)
}
})
}
}
+81
View File
@@ -0,0 +1,81 @@
package core
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
)
type packageFlags struct {
dryRun bool
}
func ParsePackageFlags(args []string) (packageFlags, string, error) {
flags := packageFlags{}
var pkg string
for _, arg := range args {
if arg == "--dry-run" {
flags.dryRun = true
continue
}
if strings.HasPrefix(arg, "-") {
return flags, "", fmt.Errorf("unknown flag %q", arg)
}
if pkg != "" {
return flags, "", errors.New("too many arguments")
}
pkg = arg
}
if pkg == "" {
return flags, "", errors.New("missing package")
}
return flags, pkg, nil
}
var stdinReader *bufio.Reader
func newReader() *bufio.Reader {
if stdinReader == nil {
stdinReader = bufio.NewReader(os.Stdin)
}
return stdinReader
}
func PromptWithDefault(label, def string) (string, error) {
reader := newReader()
if def != "" {
fmt.Printf("%s [%s]: ", label, def)
} else {
fmt.Printf("%s: ", label)
}
text, err := reader.ReadString('\n')
if err != nil {
return "", err
}
text = strings.TrimSpace(text)
if text == "" {
return def, nil
}
return text, nil
}
func PromptYesNo(message string, def bool) (bool, error) {
reader := newReader()
defLabel := "y/N"
if def {
defLabel = "Y/n"
}
fmt.Printf("%s [%s]: ", message, defLabel)
text, err := reader.ReadString('\n')
if err != nil {
return false, err
}
text = strings.TrimSpace(strings.ToLower(text))
if text == "" {
return def, nil
}
return text == "y" || text == "yes", nil
}