diff --git a/go.mod b/go.mod index cf142a2..62b6aa7 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25 require ( github.com/charmbracelet/huh v1.0.0 github.com/pelletier/go-toml/v2 v2.2.3 + github.com/spf13/afero v1.15.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 @@ -38,7 +39,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 30cc16b..c9c6bd2 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -81,14 +83,14 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/adapter/common/io.go b/internal/adapter/common/io.go index 8481fa1..9e69f5b 100644 --- a/internal/adapter/common/io.go +++ b/internal/adapter/common/io.go @@ -6,12 +6,15 @@ import ( "regexp" "strings" + "github.com/spf13/afero" + "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/fsx" ) -// ReadIfExists returns the file body or empty string when missing. -func ReadIfExists(path string) (string, error) { - b, err := os.ReadFile(path) +// ReadIfExistsFS returns the file body or empty string when missing, using fs. +func ReadIfExistsFS(fs fsx.FS, path string) (string, error) { + b, err := afero.ReadFile(fs, path) if os.IsNotExist(err) { return "", nil } @@ -21,10 +24,20 @@ func ReadIfExists(path string) (string, error) { return string(b), nil } +// ReadIfExists returns the file body or empty string when missing. +func ReadIfExists(path string) (string, error) { + return ReadIfExistsFS(fsx.OS(), path) +} + +// DirExistsFS reports whether path is an existing directory on fs. +func DirExistsFS(fs fsx.FS, path string) bool { + info, err := fs.Stat(path) + return err == nil && info.IsDir() +} + // DirExists reports whether path is an existing directory. func DirExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.IsDir() + return DirExistsFS(fsx.OS(), path) } var ( @@ -41,11 +54,10 @@ func StripJSONComments(s string) string { return strings.TrimSpace(s) } -// ImportMCPFromJSONFile reads a JSON file and extracts an MCP server map -// nested under the given top-level key (e.g. "mcpServers", "mcp"). -// Returns nil with no error when the file is missing. -func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) { - body, err := os.ReadFile(path) +// ImportMCPFromJSONFileFS reads a JSON file from fs and extracts an MCP server +// map nested under the given top-level key. Returns nil with no error when missing. +func ImportMCPFromJSONFileFS(fs fsx.FS, path, key string) ([]canonical.MCPServer, error) { + body, err := afero.ReadFile(fs, path) if os.IsNotExist(err) { return nil, nil } @@ -55,6 +67,13 @@ func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) { return ParseMCPFromJSON(body, key) } +// ImportMCPFromJSONFile reads a JSON file and extracts an MCP server map +// nested under the given top-level key (e.g. "mcpServers", "mcp"). +// Returns nil with no error when the file is missing. +func ImportMCPFromJSONFile(path, key string) ([]canonical.MCPServer, error) { + return ImportMCPFromJSONFileFS(fsx.OS(), path, key) +} + // ParseMCPFromJSON extracts MCP servers from a JSON body. The body may be // raw JSON or JSONC (comments are stripped first). Tolerates the dialect // differences across harnesses: diff --git a/internal/adapter/common/io_fs_test.go b/internal/adapter/common/io_fs_test.go new file mode 100644 index 0000000..9e3c109 --- /dev/null +++ b/internal/adapter/common/io_fs_test.go @@ -0,0 +1,71 @@ +package common_test + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" + "github.com/lukaszraczylo/harness-sync/internal/fsx" +) + +func TestReadIfExistsFSMissingReturnsEmpty(t *testing.T) { + mem := fsx.Mem() + got, err := common.ReadIfExistsFS(mem, "/no-such-file.txt") + require.NoError(t, err) + assert.Equal(t, "", got) +} + +func TestReadIfExistsFSReturnsContent(t *testing.T) { + mem := fsx.Mem() + want := "hello from memfs" + require.NoError(t, afero.WriteFile(mem, "/test.txt", []byte(want), 0o644)) + + got, err := common.ReadIfExistsFS(mem, "/test.txt") + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestImportMCPFromJSONFileFSHandlesMissingFile(t *testing.T) { + mem := fsx.Mem() + servers, err := common.ImportMCPFromJSONFileFS(mem, "/not-here.json", "mcpServers") + require.NoError(t, err) + assert.Nil(t, servers) +} + +func TestImportMCPFromJSONFileFSExtractsServers(t *testing.T) { + mem := fsx.Mem() + body := []byte(`{ + "mcpServers": { + "myserver": { + "command": "/usr/bin/mytool", + "args": ["--flag"], + "type": "stdio" + } + } + }`) + require.NoError(t, afero.WriteFile(mem, "/claude.json", body, 0o644)) + + servers, err := common.ImportMCPFromJSONFileFS(mem, "/claude.json", "mcpServers") + require.NoError(t, err) + require.Len(t, servers, 1) + assert.Equal(t, "myserver", servers[0].Name) + assert.Equal(t, "/usr/bin/mytool", servers[0].Command) + assert.Equal(t, []string{"--flag"}, servers[0].Args) + assert.Equal(t, "stdio", servers[0].Transport) +} + +func TestImportMCPFromJSONFileFSReadOnlyFS(t *testing.T) { + // Populate a mem fs then wrap it read-only — reads should still work. + mem := fsx.Mem() + body := []byte(`{"mcpServers": {"srv": {"command": "bin"}}}`) + require.NoError(t, afero.WriteFile(mem, "/config.json", body, 0o644)) + + roFS := afero.NewReadOnlyFs(mem) + servers, err := common.ImportMCPFromJSONFileFS(roFS, "/config.json", "mcpServers") + require.NoError(t, err) + require.Len(t, servers, 1) + assert.Equal(t, "srv", servers[0].Name) +} diff --git a/internal/adapter/common/markdown.go b/internal/adapter/common/markdown.go index dd2c94e..3a46b87 100644 --- a/internal/adapter/common/markdown.go +++ b/internal/adapter/common/markdown.go @@ -5,7 +5,10 @@ import ( "path/filepath" "strings" + "github.com/spf13/afero" "gopkg.in/yaml.v3" + + "github.com/lukaszraczylo/harness-sync/internal/fsx" ) // Doc mirrors canonical.Skill / canonical.Agent without importing them. @@ -35,15 +38,15 @@ func ParseFrontmatter(b []byte) (name, description string) { return meta.Name, meta.Description } -// ImportMarkdownTree walks dir and returns one Doc per markdown file. When -// requiredFilename is non-empty (e.g. "SKILL.md"), only files with that exact -// basename are included; otherwise any *.md. -func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) { +// ImportMarkdownTreeFS walks dir on fs and returns one Doc per matching file. +// When requiredFilename is non-empty (e.g. "SKILL.md"), only files with that +// exact basename are included; otherwise any *.md. +func ImportMarkdownTreeFS(fs fsx.FS, dir, requiredFilename string) ([]Doc, error) { var docs []Doc - if !DirExists(dir) { + if !DirExistsFS(fs, dir) { return docs, nil } - err := filepath.Walk(dir, func(p string, info os.FileInfo, err error) error { + err := afero.Walk(fs, dir, func(p string, info os.FileInfo, err error) error { if err != nil { return err } @@ -56,7 +59,7 @@ func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) { if requiredFilename == "" && !strings.HasSuffix(p, ".md") { return nil } - body, err := os.ReadFile(p) + body, err := afero.ReadFile(fs, p) if err != nil { return err } @@ -75,3 +78,10 @@ func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) { }) return docs, err } + +// ImportMarkdownTree walks dir and returns one Doc per markdown file. When +// requiredFilename is non-empty (e.g. "SKILL.md"), only files with that exact +// basename are included; otherwise any *.md. +func ImportMarkdownTree(dir, requiredFilename string) ([]Doc, error) { + return ImportMarkdownTreeFS(fsx.OS(), dir, requiredFilename) +} diff --git a/internal/adapter/common/markdown_fs_test.go b/internal/adapter/common/markdown_fs_test.go new file mode 100644 index 0000000..d5416a4 --- /dev/null +++ b/internal/adapter/common/markdown_fs_test.go @@ -0,0 +1,64 @@ +package common_test + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/adapter/common" + "github.com/lukaszraczylo/harness-sync/internal/fsx" +) + +func TestImportMarkdownTreeFSPicksUpSkillsFromMemFS(t *testing.T) { + mem := fsx.Mem() + + body := []byte("---\nname: cool-skill\ndescription: does stuff\n---\n# Cool Skill\n") + require.NoError(t, mem.MkdirAll("/skills/cool-skill", 0o755)) + require.NoError(t, afero.WriteFile(mem, "/skills/cool-skill/SKILL.md", body, 0o644)) + + docs, err := common.ImportMarkdownTreeFS(mem, "/skills", "SKILL.md") + require.NoError(t, err) + require.Len(t, docs, 1) + assert.Equal(t, "cool-skill", docs[0].Name) + assert.Equal(t, "does stuff", docs[0].Description) + assert.Contains(t, docs[0].Body, "# Cool Skill") +} + +func TestImportMarkdownTreeFSHandlesMissingDir(t *testing.T) { + mem := fsx.Mem() + + docs, err := common.ImportMarkdownTreeFS(mem, "/no-such-dir", "SKILL.md") + require.NoError(t, err) + assert.Empty(t, docs) +} + +func TestImportMarkdownTreeFSSkipsNonMatching(t *testing.T) { + mem := fsx.Mem() + + require.NoError(t, mem.MkdirAll("/skills/a", 0o755)) + require.NoError(t, mem.MkdirAll("/skills/b", 0o755)) + require.NoError(t, afero.WriteFile(mem, "/skills/a/SKILL.md", []byte("---\nname: a\n---\n"), 0o644)) + require.NoError(t, afero.WriteFile(mem, "/skills/b/OTHER.md", []byte("# other"), 0o644)) + require.NoError(t, afero.WriteFile(mem, "/skills/b/notes.txt", []byte("text"), 0o644)) + + // requiredFilename = "SKILL.md" → only picks up a/SKILL.md + docs, err := common.ImportMarkdownTreeFS(mem, "/skills", "SKILL.md") + require.NoError(t, err) + require.Len(t, docs, 1) + assert.Equal(t, "a", docs[0].Name) +} + +func TestImportMarkdownTreeFSAnyMDWhenNoRequired(t *testing.T) { + mem := fsx.Mem() + + require.NoError(t, mem.MkdirAll("/agents", 0o755)) + require.NoError(t, afero.WriteFile(mem, "/agents/alpha.md", []byte("# Alpha\n"), 0o644)) + require.NoError(t, afero.WriteFile(mem, "/agents/beta.md", []byte("# Beta\n"), 0o644)) + require.NoError(t, afero.WriteFile(mem, "/agents/skip.txt", []byte("not md"), 0o644)) + + docs, err := common.ImportMarkdownTreeFS(mem, "/agents", "") + require.NoError(t, err) + assert.Len(t, docs, 2) +} diff --git a/internal/canonical/load.go b/internal/canonical/load.go index 9e1b18e..60a78d6 100644 --- a/internal/canonical/load.go +++ b/internal/canonical/load.go @@ -6,11 +6,22 @@ import ( "path/filepath" "strings" + "github.com/spf13/afero" "gopkg.in/yaml.v3" + + "github.com/lukaszraczylo/harness-sync/internal/fsx" ) +// Load loads a canonical Bundle from the given root directory using the OS +// filesystem. It is a thin wrapper around LoadFS. func Load(root string) (*Bundle, error) { - info, err := os.Stat(root) + return LoadFS(fsx.OS(), root) +} + +// LoadFS loads a canonical Bundle from root using the provided filesystem. +// Use fsx.Mem() in tests to avoid touching real disk. +func LoadFS(fs fsx.FS, root string) (*Bundle, error) { + info, err := fs.Stat(root) if err != nil { return nil, fmt.Errorf("canonical root %s: %w", root, err) } @@ -20,7 +31,7 @@ func Load(root string) (*Bundle, error) { b := &Bundle{Root: root} - err = loadYAML(filepath.Join(root, "config.yaml"), &b.Config) + err = loadYAMLFS(fs, filepath.Join(root, "config.yaml"), &b.Config) if err != nil { return nil, err } @@ -29,7 +40,7 @@ func Load(root string) (*Bundle, error) { } profPath := filepath.Join(root, "profiles", b.Config.ActiveProfile+".yaml") - err = loadYAML(profPath, &b.Profile) + err = loadYAMLFS(fs, profPath, &b.Profile) if err != nil { return nil, fmt.Errorf("profile %q: %w", b.Config.ActiveProfile, err) } @@ -38,14 +49,14 @@ func Load(root string) (*Bundle, error) { } mcpPath := filepath.Join(root, "mcp.yaml") - if fileExists(mcpPath) { - err = loadYAML(mcpPath, &b.MCP) + if fileExistsFS(fs, mcpPath) { + err = loadYAMLFS(fs, mcpPath, &b.MCP) if err != nil { return nil, err } } - skills, err := loadMarkdownDir(filepath.Join(root, "skills"), "SKILL.md") + skills, err := loadMarkdownDirFS(fs, filepath.Join(root, "skills"), "SKILL.md") if err != nil { return nil, err } @@ -53,7 +64,7 @@ func Load(root string) (*Bundle, error) { b.Skills = append(b.Skills, Skill(m)) } - agents, err := loadMarkdownDir(filepath.Join(root, "agents"), "") + agents, err := loadMarkdownDirFS(fs, filepath.Join(root, "agents"), "") if err != nil { return nil, err } @@ -62,24 +73,35 @@ func Load(root string) (*Bundle, error) { } b.Instructions.PerHarness = map[string]string{} - body, err := readFileIfExists(filepath.Join(root, "instructions", "global.md")) + body, err := readFileIfExistsFS(fs, filepath.Join(root, "instructions", "global.md")) if err != nil { return nil, err } b.Instructions.Global = body perHarnessDir := filepath.Join(root, "instructions", "per-harness") - if entries, err := os.ReadDir(perHarnessDir); err == nil { + if dirExistsFS(fs, perHarnessDir) { + dir, err := fs.Open(perHarnessDir) + if err != nil { + return nil, err + } + entries, err := dir.Readdir(-1) + if closeErr := dir.Close(); closeErr != nil && err == nil { + err = closeErr + } + if err != nil { + return nil, err + } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { continue } - body, err := os.ReadFile(filepath.Join(perHarnessDir, e.Name())) + entryBody, err := afero.ReadFile(fs, filepath.Join(perHarnessDir, e.Name())) if err != nil { return nil, err } harness := strings.TrimSuffix(e.Name(), ".md") - b.Instructions.PerHarness[harness] = string(body) + b.Instructions.PerHarness[harness] = string(entryBody) } } @@ -93,12 +115,13 @@ type markdownDoc struct { Path string } -func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) { +// loadMarkdownDirFS walks dir on fs and returns one markdownDoc per matching file. +func loadMarkdownDirFS(fs fsx.FS, dir, requiredFilename string) ([]markdownDoc, error) { var docs []markdownDoc - if !dirExists(dir) { + if !dirExistsFS(fs, dir) { return docs, nil } - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + err := afero.Walk(fs, dir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } @@ -111,7 +134,7 @@ func loadMarkdownDir(dir, requiredFilename string) ([]markdownDoc, error) { if requiredFilename == "" && !strings.HasSuffix(path, ".md") { return nil } - body, err := os.ReadFile(path) + body, err := afero.ReadFile(fs, path) if err != nil { return err } @@ -149,8 +172,8 @@ func parseFrontmatter(b []byte) (name, description string) { return meta.Name, meta.Description } -func loadYAML(path string, out interface{}) error { - b, err := os.ReadFile(path) +func loadYAMLFS(fs fsx.FS, path string, out interface{}) error { + b, err := afero.ReadFile(fs, path) if err != nil { return fmt.Errorf("read %s: %w", path, err) } @@ -160,9 +183,9 @@ func loadYAML(path string, out interface{}) error { return nil } -func readFileIfExists(path string) (string, error) { - b, err := os.ReadFile(path) - if os.IsNotExist(err) { +func readFileIfExistsFS(fs fsx.FS, path string) (string, error) { + b, err := afero.ReadFile(fs, path) + if err != nil && os.IsNotExist(err) { return "", nil } if err != nil { @@ -171,12 +194,12 @@ func readFileIfExists(path string) (string, error) { return string(b), nil } -func fileExists(path string) bool { - info, err := os.Stat(path) +func fileExistsFS(fs fsx.FS, path string) bool { + info, err := fs.Stat(path) return err == nil && !info.IsDir() } -func dirExists(path string) bool { - info, err := os.Stat(path) +func dirExistsFS(fs fsx.FS, path string) bool { + info, err := fs.Stat(path) return err == nil && info.IsDir() } diff --git a/internal/canonical/load_fs_test.go b/internal/canonical/load_fs_test.go new file mode 100644 index 0000000..ad47549 --- /dev/null +++ b/internal/canonical/load_fs_test.go @@ -0,0 +1,79 @@ +package canonical_test + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/canonical" + "github.com/lukaszraczylo/harness-sync/internal/fsx" +) + +// writeFile is a convenience helper: writes bytes to path on fs, creating any +// parent directories first. +func writeFile(t *testing.T, fs fsx.FS, path string, content []byte) { + t.Helper() + require.NoError(t, fs.MkdirAll(parentDir(path), 0o755)) + require.NoError(t, afero.WriteFile(fs, path, content, 0o644)) +} + +func parentDir(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '/' { + return path[:i] + } + } + return "/" +} + +func TestLoadFSFromMemFS(t *testing.T) { + mem := fsx.Mem() + + configYAML := []byte("active_profile: test\n") + profileYAML := []byte("name: Test Profile\ngateway:\n url: https://example.com\n token: tok\n default_model: gpt4\n") + skillBody := []byte("---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\nsome content\n") + + writeFile(t, mem, "/root/config.yaml", configYAML) + writeFile(t, mem, "/root/profiles/test.yaml", profileYAML) + writeFile(t, mem, "/root/skills/my-skill/SKILL.md", skillBody) + + bundle, err := canonical.LoadFS(mem, "/root") + require.NoError(t, err) + require.NotNil(t, bundle) + + assert.Equal(t, "test", bundle.Config.ActiveProfile) + assert.Equal(t, "Test Profile", bundle.Profile.Name) + require.Len(t, bundle.Skills, 1) + assert.Equal(t, "my-skill", bundle.Skills[0].Name) + assert.Equal(t, "A test skill", bundle.Skills[0].Description) +} + +func TestLoadFSEmptyProfile(t *testing.T) { + mem := fsx.Mem() + + // config.yaml present but profile file is missing + writeFile(t, mem, "/root/config.yaml", []byte("active_profile: missing\n")) + + _, err := canonical.LoadFS(mem, "/root") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing") +} + +func TestLoadFSMissingRoot(t *testing.T) { + mem := fsx.Mem() + + _, err := canonical.LoadFS(mem, "/does-not-exist") + require.Error(t, err) +} + +func TestLoadFSActiveProfileRequired(t *testing.T) { + mem := fsx.Mem() + + writeFile(t, mem, "/root/config.yaml", []byte("enabled_harnesses: []\n")) + + _, err := canonical.LoadFS(mem, "/root") + require.Error(t, err) + assert.Contains(t, err.Error(), "active_profile is required") +} diff --git a/internal/fsx/fsx.go b/internal/fsx/fsx.go new file mode 100644 index 0000000..10cee6a --- /dev/null +++ b/internal/fsx/fsx.go @@ -0,0 +1,29 @@ +// Package fsx provides a small filesystem abstraction so file-touching code +// can be tested against an in-memory filesystem. +package fsx + +import ( + "os" + + "github.com/spf13/afero" +) + +// FS is the harness-sync filesystem interface — alias for afero.Fs so the +// dependency stays in one place. +type FS = afero.Fs + +// OS returns the production OS-backed filesystem. +func OS() FS { return afero.NewOsFs() } + +// Mem returns an empty in-memory filesystem for tests. +func Mem() FS { return afero.NewMemMapFs() } + +// WriteFile writes data to path on fs. +func WriteFile(fs FS, path string, data []byte, perm os.FileMode) error { + return afero.WriteFile(fs, path, data, perm) +} + +// ReadFile reads the named file from fs and returns its contents. +func ReadFile(fs FS, path string) ([]byte, error) { + return afero.ReadFile(fs, path) +} diff --git a/internal/fsx/fsx_test.go b/internal/fsx/fsx_test.go new file mode 100644 index 0000000..9055f65 --- /dev/null +++ b/internal/fsx/fsx_test.go @@ -0,0 +1,34 @@ +package fsx_test + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/harness-sync/internal/fsx" +) + +func TestOSReturnsAferoOsFs(t *testing.T) { + fs := fsx.OS() + require.NotNil(t, fs) + _, ok := fs.(*afero.OsFs) + assert.True(t, ok, "OS() should return *afero.OsFs") +} + +func TestMemReturnsAferoMemFs(t *testing.T) { + fs := fsx.Mem() + require.NotNil(t, fs) + _, ok := fs.(*afero.MemMapFs) + assert.True(t, ok, "Mem() should return *afero.MemMapFs") +} + +func TestWriteReadRoundtrip(t *testing.T) { + fs := fsx.Mem() + data := []byte("hello world") + require.NoError(t, fsx.WriteFile(fs, "/test.txt", data, 0o644)) + got, err := fsx.ReadFile(fs, "/test.txt") + require.NoError(t, err) + assert.Equal(t, data, got) +}