feat(adapter): define Adapter interface and FileSet

This commit is contained in:
2026-05-23 02:45:50 +01:00
parent e4719b78a0
commit 6ab8d149e1
5 changed files with 175 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
// Package adapter defines the harness adapter interface and FileSet.
package adapter
import "github.com/lukaszraczylo/harness-sync/internal/canonical"
type Kind int
const (
RenderedFile Kind = iota
SymlinkFile
SymlinkDir
)
func (k Kind) String() string {
switch k {
case RenderedFile:
return "rendered"
case SymlinkFile:
return "symlink-file"
case SymlinkDir:
return "symlink-dir"
}
return "unknown"
}
type Adapter interface {
Name() string
Detect() bool
Render(b *canonical.Bundle) (*FileSet, error)
Import(home string) (*ImportResult, error)
}
type ImportResult struct {
Instructions string
Skills []canonical.Skill
Agents []canonical.Agent
MCP []canonical.MCPServer
Profiles []canonical.Profile
}
+25
View File
@@ -0,0 +1,25 @@
package adapter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFileSetAddAndIterate(t *testing.T) {
fs := NewFileSet()
fs.Add(File{Dest: "/tmp/a", Kind: RenderedFile, Content: []byte("a")})
fs.Add(File{Dest: "/tmp/b", Kind: SymlinkFile, SymlinkTarget: "/canon/b"})
seen := map[string]Kind{}
fs.ForEach(func(f File) {
seen[f.Dest] = f.Kind
})
assert.Equal(t, RenderedFile, seen["/tmp/a"])
assert.Equal(t, SymlinkFile, seen["/tmp/b"])
}
func TestKindString(t *testing.T) {
assert.Equal(t, "rendered", RenderedFile.String())
assert.Equal(t, "symlink-file", SymlinkFile.String())
}
+32
View File
@@ -0,0 +1,32 @@
package adapter
import "os"
type File struct {
Dest string
SymlinkTarget string
Content []byte
Mode os.FileMode
Kind Kind
}
type FileSet struct {
files []File
}
func NewFileSet() *FileSet { return &FileSet{} }
func (fs *FileSet) Add(f File) {
if f.Mode == 0 && f.Kind == RenderedFile {
f.Mode = 0o644
}
fs.files = append(fs.files, f)
}
func (fs *FileSet) ForEach(fn func(File)) {
for _, f := range fs.files {
fn(f)
}
}
func (fs *FileSet) Len() int { return len(fs.files) }
+47
View File
@@ -0,0 +1,47 @@
// Package secrets provides ${VAR} substitution for canonical configs.
package secrets
import (
"fmt"
"os"
"strings"
)
// Lookup resolves a placeholder name to a value. Returns false if missing.
type Lookup func(name string) (string, bool)
// OSEnv resolves via os.LookupEnv.
func OSEnv(name string) (string, bool) {
return os.LookupEnv(name)
}
// Substitute replaces ${NAME} placeholders in s using lookup. Escape with $$.
// Missing keys produce an error so secrets are never silently empty.
func Substitute(s string, lookup Lookup) (string, error) {
var b strings.Builder
i := 0
for i < len(s) {
if i+1 < len(s) && s[i] == '$' && s[i+1] == '$' {
b.WriteByte('$')
i += 2
continue
}
if i+1 < len(s) && s[i] == '$' && s[i+1] == '{' {
end := strings.IndexByte(s[i+2:], '}')
if end == -1 {
return "", fmt.Errorf("unterminated placeholder at offset %d", i)
}
name := s[i+2 : i+2+end]
val, ok := lookup(name)
if !ok {
return "", fmt.Errorf("missing env var %q", name)
}
b.WriteString(val)
i += 2 + end + 1
continue
}
b.WriteByte(s[i])
i++
}
return b.String(), nil
}
+32
View File
@@ -0,0 +1,32 @@
package secrets
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSubstituteSimple(t *testing.T) {
lookup := func(k string) (string, bool) {
if k == "FOO" {
return "bar", true
}
return "", false
}
out, err := Substitute("hello ${FOO}", lookup)
assert.NoError(t, err)
assert.Equal(t, "hello bar", out)
}
func TestSubstituteMissingStrict(t *testing.T) {
lookup := func(k string) (string, bool) { return "", false }
_, err := Substitute("${MISSING}", lookup)
assert.Error(t, err)
}
func TestSubstituteEscaped(t *testing.T) {
lookup := func(k string) (string, bool) { return "should-not-see", true }
out, err := Substitute("$${LITERAL}", lookup)
assert.NoError(t, err)
assert.Equal(t, "${LITERAL}", out)
}