From 44f523853899076d61fb7c9c08614d73bbfd9bbb Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 4 Aug 2026 12:27:47 +0200 Subject: [PATCH] fsedsl: comprehensive test coverage (95.8%) - Add tests for Adapter methods (NewAdapter, all wrapper methods) - Add tests for all NodeOption functions (Stream, BlockOnce, Request, CreateFile, RemoveNode, StatOverride, UID, GID, Alias, Bind) - Add tests for Tree mount/child/split operations - Add tests for FileConfig methods and mode validation - Add tests for SyntheticFileInfo and SyntheticEntry - Add tests for builder delete/rename operations - Add tests for blocking/streaming read modes in openLeaf - Add tests for validation panics - Add tree_test.go for filesystem-backed Tree operations: - Create, Delete, Rename, MkdirAll - ReadOnly mode enforcement - Union directories - Child delegation - Resolver and opener fallbacks Coverage improved from 45.5% to 95.8% --- fsedsl/decl_test.go | 1470 ++++++++++++++++++++++++++++++++++++++++++- fsedsl/tree_test.go | 603 ++++++++++++++++++ 2 files changed, 2065 insertions(+), 8 deletions(-) create mode 100644 fsedsl/tree_test.go diff --git a/fsedsl/decl_test.go b/fsedsl/decl_test.go index 3eb9a98..052c044 100644 --- a/fsedsl/decl_test.go +++ b/fsedsl/decl_test.go @@ -1,19 +1,1476 @@ package fsedsl import ( + "context" + "errors" "os" "testing" ) // TestCtx is a simple test context. type TestCtx struct { - Data any + Data any + UserID string } -// TestEachBasic tests the basic Blueprint pattern: -// - Declares structure statically -// - Bindings function provides identity (name, aliases, UID/GID) -// - Handlers receive context with bound data +// ══════════════════════════════════════════════════════════════════ +// Adapter tests +// ══════════════════════════════════════════════════════════════════ + +func TestAdapter(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + // Test all adapter methods produce equivalent results to direct functions + spec := edsl.DirNode("/", + edsl.FileNode("version", 0444, + edsl.Doc("API version"), + edsl.Read(func(ctx TestCtx) ([]byte, error) { + return []byte("1.0\n"), nil + }), + ), + edsl.FileNode("config", 0666, + edsl.Read(func(ctx TestCtx) ([]byte, error) { + return []byte("cfg\n"), nil + }), + edsl.Write(func(ctx TestCtx, data []byte) error { + return nil + }), + edsl.UID("root"), + edsl.GID("wheel"), + edsl.Alias("configuration"), + ), + edsl.Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return []Binding[TestCtx]{{Name: "item1"}}, nil + }, + edsl.FileNode("data", 0444, edsl.Read(func(ctx TestCtx) ([]byte, error) { + return []byte("data\n"), nil + })), + edsl.Bind(BindData), + ), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + if tree == nil { + t.Fatal("BuildTree returned nil") + } + + // Test List + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 3 { // version, config, item1 + t.Errorf("List: got %d entries, want 3", len(entries)) + } + + // Test GenerateHelp via adapter + help := edsl.GenerateHelp(spec) + if help == "" { + t.Error("GenerateHelp returned empty") + } + if !contains(help, "API version") { + t.Error("help missing 'API version'") + } +} + +func TestAdapterStream(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + streamCalled := false + spec := edsl.DirNode("/", + edsl.FileNode("stream", 0444, + edsl.Stream(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { + streamCalled = true + return []byte("streamed"), "next", nil + }), + ), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + f, err := tree.Open("stream") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !f.StreamMode() { + t.Error("expected StreamMode() = true") + } + data, next, err := f.BlockingRead(context.Background(), "") + if err != nil { + t.Fatalf("BlockingRead: %v", err) + } + if !streamCalled { + t.Error("stream handler not called") + } + if string(data) != "streamed" { + t.Errorf("data = %q, want 'streamed'", data) + } + if next != "next" { + t.Errorf("next = %q, want 'next'", next) + } +} + +func TestAdapterBlockOnce(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + blockCalled := false + spec := edsl.DirNode("/", + edsl.FileNode("block", 0444, + edsl.BlockOnce(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { + blockCalled = true + return []byte("blocked"), "done", nil + }), + ), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + f, err := tree.Open("block") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !f.BlockingReadMode() { + t.Error("expected BlockingReadMode() = true") + } + data, next, err := f.BlockingRead(context.Background(), "") + if err != nil { + t.Fatalf("BlockingRead: %v", err) + } + if !blockCalled { + t.Error("block handler not called") + } + if string(data) != "blocked" { + t.Errorf("data = %q, want 'blocked'", data) + } + if next != "done" { + t.Errorf("next = %q, want 'done'", next) + } +} + +func TestAdapterRequest(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + spec := edsl.DirNode("/", + edsl.FileNode("rdwr", 0666, + edsl.Request(func(ctx TestCtx, data []byte) ([]byte, error) { + return []byte("response:" + string(data)), nil + }), + ), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + f, err := tree.Open("rdwr") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !f.RequestRespMode() { + t.Error("expected RequestRespMode() = true") + } + + // Write triggers request handler + err = f.Write([]byte("hello")) + if err != nil { + t.Fatalf("Write: %v", err) + } + + // Read returns response + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "response:hello" { + t.Errorf("data = %q, want 'response:hello'", data) + } + + // Second read returns nil (result consumed) + data2, err := f.Read() + if err != nil { + t.Fatalf("Read2: %v", err) + } + if data2 != nil { + t.Errorf("second read = %q, want nil", data2) + } +} + +func TestAdapterCreateFile(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + var created string + spec := edsl.DirNode("/", + edsl.FileNode("dummy", 0444), + edsl.CreateFile(func(ctx TestCtx, name string) error { + created = name + return nil + }), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + err := tree.Create("newfile") + if err != nil { + t.Fatalf("Create: %v", err) + } + if created != "newfile" { + t.Errorf("created = %q, want 'newfile'", created) + } +} + +func TestAdapterRemove(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + var removed bool + spec := edsl.DirNode("/", + edsl.Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return []Binding[TestCtx]{{Name: "item", Remove: func() { removed = true }}}, nil + }, + edsl.FileNode("data", 0444), + edsl.Remove(func(ctx TestCtx) error { + removed = true + return nil + }), + ), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + err := tree.Delete("item") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if !removed { + t.Error("remove handler not called") + } +} + +func TestAdapterStat(t *testing.T) { + edsl := NewAdapter[TestCtx]() + + spec := edsl.DirNode("/", + edsl.FileNode("custom", 0444, + edsl.Stat(func() os.FileInfo { + return &SyntheticFileInfo{Name_: "custom", Mode_: 0555, Size_: 42} + }), + ), + ) + + tree := edsl.BuildTree(spec, TestCtx{}) + f, err := tree.Open("custom") + if err != nil { + t.Fatalf("Open: %v", err) + } + fi, err := f.Stat() + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.Mode() != 0555 { + t.Errorf("Mode = %o, want 0555", fi.Mode()) + } + if fi.Size() != 42 { + t.Errorf("Size = %d, want 42", fi.Size()) + } +} + +// ══════════════════════════════════════════════════════════════════ +// NodeOption tests +// ══════════════════════════════════════════════════════════════════ + +func TestNodeOptions(t *testing.T) { + // Test Stream option + t.Run("Stream", func(t *testing.T) { + streamFn := func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { + return nil, "", nil + } + spec := FileNode[TestCtx]("test", 0444, Stream(streamFn)) + if spec.Stream == nil { + t.Error("Stream not set") + } + }) + + // Test BlockOnce option + t.Run("BlockOnce", func(t *testing.T) { + blockFn := func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { + return nil, "", nil + } + spec := FileNode[TestCtx]("test", 0444, BlockOnce(blockFn)) + if spec.BlockOnce == nil { + t.Error("BlockOnce not set") + } + }) + + // Test Request option + t.Run("Request", func(t *testing.T) { + reqFn := func(ctx TestCtx, data []byte) ([]byte, error) { + return nil, nil + } + spec := FileNode[TestCtx]("test", 0666, Request(reqFn)) + if spec.Request == nil { + t.Error("Request not set") + } + }) + + // Test CreateFile option + t.Run("CreateFile", func(t *testing.T) { + createFn := func(ctx TestCtx, name string) error { + return nil + } + spec := DirNode[TestCtx]("test", + FileNode[TestCtx]("child", 0444), + CreateFile[TestCtx](createFn), + ) + if spec.Create == nil { + t.Error("Create not set") + } + }) + + // Test RemoveNode option + t.Run("RemoveNode", func(t *testing.T) { + removeFn := func(ctx TestCtx) error { + return nil + } + spec := DirNode[TestCtx]("test", + FileNode[TestCtx]("child", 0444), + RemoveNode[TestCtx](removeFn), + ) + if spec.Remove == nil { + t.Error("Remove not set") + } + }) + + // Test StatOverride option + t.Run("StatOverride", func(t *testing.T) { + statFn := func() os.FileInfo { + return &SyntheticFileInfo{Name_: "test"} + } + spec := FileNode[TestCtx]("test", 0444, StatOverride[TestCtx](statFn)) + if spec.Stat == nil { + t.Error("Stat not set") + } + }) + + // Test UID option + t.Run("UID", func(t *testing.T) { + spec := FileNode[TestCtx]("test", 0444, UID[TestCtx]("owner")) + if spec.UID != "owner" { + t.Errorf("UID = %q, want 'owner'", spec.UID) + } + }) + + // Test GID option + t.Run("GID", func(t *testing.T) { + spec := FileNode[TestCtx]("test", 0444, GID[TestCtx]("group")) + if spec.GID != "group" { + t.Errorf("GID = %q, want 'group'", spec.GID) + } + }) + + // Test Alias option + t.Run("Alias", func(t *testing.T) { + spec := FileNode[TestCtx]("test", 0444, Alias[TestCtx]("alt1", "alt2")) + if len(spec.Aliases) != 2 { + t.Errorf("Aliases = %v, want 2 elements", spec.Aliases) + } + if spec.Aliases[0] != "alt1" || spec.Aliases[1] != "alt2" { + t.Errorf("Aliases = %v, want [alt1, alt2]", spec.Aliases) + } + }) + + // Test Bind option + t.Run("Bind", func(t *testing.T) { + spec := DirNode[TestCtx]("test", + FileNode[TestCtx]("child", 0444), + Bind[TestCtx](BindData), + ) + if len(spec.BindTargets) != 1 { + t.Errorf("BindTargets = %v, want 1 element", spec.BindTargets) + } + }) +} + +// ══════════════════════════════════════════════════════════════════ +// Tree tests +// ══════════════════════════════════════════════════════════════════ + +func TestTreeMount(t *testing.T) { + parent := NewTree(nil, 0755) + child := NewTree(nil, 0755, + WithLister(func(_ []string) ([]os.DirEntry, error) { + return []os.DirEntry{FileEntry("childfile", 0444)}, nil + }), + ) + + parent.Mount("subdir", child) + + // Test Child retrieval + got := parent.Child("subdir") + if got != child { + t.Error("Child did not return mounted child") + } + + // Test Children map + children := parent.Children() + if len(children) != 1 { + t.Errorf("Children = %d, want 1", len(children)) + } + if children["subdir"] != child { + t.Error("Children map missing 'subdir'") + } + + // Test nil child + nilChild := parent.Child("nonexistent") + if nilChild != nil { + t.Error("Child should return nil for nonexistent") + } +} + +func TestTreeSplit(t *testing.T) { + parent := NewTree(nil, 0755) + child := NewTree(nil, 0755) + parent.Mount("sub", child) + + // Test exact match + c, rest, ok := parent.split("sub") + if !ok { + t.Error("split should find 'sub'") + } + if c != child { + t.Error("split returned wrong child") + } + if rest != "" { + t.Errorf("rest = %q, want ''", rest) + } + + // Test prefix match + c2, rest2, ok2 := parent.split("sub/file") + if !ok2 { + t.Error("split should find 'sub/file'") + } + if c2 != child { + t.Error("split returned wrong child") + } + if rest2 != "file" { + t.Errorf("rest = %q, want 'file'", rest2) + } + + // Test no match + _, _, ok3 := parent.split("other") + if ok3 { + t.Error("split should not find 'other'") + } + + // Test tree without children + empty := NewTree(nil, 0755) + _, _, ok4 := empty.split("anything") + if ok4 { + t.Error("split should not find anything in empty tree") + } +} + +func TestTreeWithReadOnly(t *testing.T) { + tree := NewTree(nil, 0644, WithReadOnly()) + if !tree.readOnly { + t.Error("WithReadOnly not applied") + } +} + +func TestTreeWithIndex(t *testing.T) { + tree := NewTree(nil, 0755, + WithIndex(func(ft FileTree) ([]byte, error) { + return []byte("index content"), nil + }), + ) + + // Stat idx + fi, err := tree.Stat("idx") + if err != nil { + t.Fatalf("Stat(idx): %v", err) + } + if fi.Name() != "idx" { + t.Errorf("Name = %q, want 'idx'", fi.Name()) + } + + // Open idx + f, err := tree.Open("idx") + if err != nil { + t.Fatalf("Open(idx): %v", err) + } + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "index content" { + t.Errorf("data = %q, want 'index content'", data) + } + + // Write to idx should fail + err = f.Write([]byte("test")) + if err == nil { + t.Error("Write to idx should fail") + } + + // List should include idx + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + found := false + for _, e := range entries { + if e.Name() == "idx" { + found = true + break + } + } + if !found { + t.Error("List should include idx") + } +} + +func TestTreeWithResolver(t *testing.T) { + tree := NewTree([]string{"/dummy"}, 0755, + WithResolver(func(dirs []string, name string) (string, error) { + if name == "found" { + return "/resolved/path", nil + } + return "", errors.New("not found") + }), + ) + + // This tests the resolve function path + _, err := tree.Stat("notfound") + if err == nil { + t.Error("Stat should fail for unresolved name") + } +} + +func TestTreeIndexOf(t *testing.T) { + // Test indexOf helper + if indexOf("hello/world", '/') != 5 { + t.Error("indexOf failed for slash") + } + if indexOf("noslash", '/') != -1 { + t.Error("indexOf should return -1") + } +} + +// ══════════════════════════════════════════════════════════════════ +// FileConfig tests +// ══════════════════════════════════════════════════════════════════ + +func TestFileConfigMethods(t *testing.T) { + fc := &FileConfig{ + StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "test"}, nil }, + ReadFn: func() ([]byte, error) { return []byte("content"), nil }, + WriteFn: func(data []byte) error { return nil }, + BlockingReadFn: func(ctx context.Context, base string) ([]byte, string, error) { return nil, "", nil }, + } + + // Stat + fi, err := fc.Stat() + if err != nil || fi.Name() != "test" { + t.Error("Stat failed") + } + + // Read + data, err := fc.Read() + if err != nil || string(data) != "content" { + t.Error("Read failed") + } + + // Write + err = fc.Write([]byte("x")) + if err != nil { + t.Error("Write failed") + } + + // BlockingRead + _, _, err = fc.BlockingRead(context.Background(), "") + if err != nil { + t.Error("BlockingRead failed") + } +} + +func TestFileConfigModes(t *testing.T) { + // Default: all false + fc := &FileConfig{} + if fc.BlockingReadMode() || fc.StreamMode() || fc.RequestRespMode() { + t.Error("default modes should be false") + } + + // BlockingReadMode + fc2 := &FileConfig{BlockingRead_: true} + if !fc2.BlockingReadMode() { + t.Error("BlockingReadMode should be true") + } + + // StreamMode + fc3 := &FileConfig{StreamMode_: true} + if !fc3.StreamMode() { + t.Error("StreamMode should be true") + } + + // RequestRespMode + fc4 := &FileConfig{RequestResp_: true} + if !fc4.RequestRespMode() { + t.Error("RequestRespMode should be true") + } +} + +func TestFileConfigValidateReadMode(t *testing.T) { + // Valid: none set + fc := &FileConfig{} + fc.ValidateReadMode() // should not panic + + // Valid: one set + fc2 := &FileConfig{BlockingRead_: true} + fc2.ValidateReadMode() // should not panic + + // Invalid: two set + defer func() { + if r := recover(); r == nil { + t.Error("ValidateReadMode should panic when multiple modes set") + } + }() + fc3 := &FileConfig{BlockingRead_: true, StreamMode_: true} + fc3.ValidateReadMode() +} + +// ══════════════════════════════════════════════════════════════════ +// SyntheticFileInfo tests +// ══════════════════════════════════════════════════════════════════ + +func TestSyntheticFileInfo(t *testing.T) { + fi := &SyntheticFileInfo{ + Name_: "testfile", + Mode_: 0644, + Size_: 100, + IsDir_: false, + UID_: "user", + GID_: "group", + Vers_: 1, + } + + if fi.Name() != "testfile" { + t.Errorf("Name = %q", fi.Name()) + } + if fi.Size() != 100 { + t.Errorf("Size = %d", fi.Size()) + } + if fi.Mode() != 0644 { + t.Errorf("Mode = %o", fi.Mode()) + } + if !fi.ModTime().IsZero() { + t.Error("ModTime should be zero") + } + if fi.IsDir() { + t.Error("IsDir should be false") + } + if fi.Sys() != nil { + t.Error("Sys should be nil") + } + + // Test directory + fiDir := &SyntheticFileInfo{IsDir_: true} + if !fiDir.IsDir() { + t.Error("IsDir should be true") + } +} + +// ══════════════════════════════════════════════════════════════════ +// SyntheticEntry tests +// ══════════════════════════════════════════════════════════════════ + +func TestSyntheticEntry(t *testing.T) { + // File entry + fe := &SyntheticEntry{Name_: "file", Mode_: 0644, IsDir_: false} + if fe.Name() != "file" { + t.Errorf("Name = %q", fe.Name()) + } + if fe.IsDir() { + t.Error("IsDir should be false") + } + if fe.Type() != 0 { + t.Errorf("Type = %v, want 0", fe.Type()) + } + fi, err := fe.Info() + if err != nil { + t.Fatalf("Info: %v", err) + } + if fi.Name() != "file" || fi.Mode() != 0644 { + t.Error("Info returned wrong values") + } + + // Dir entry + de := &SyntheticEntry{Name_: "dir", Mode_: 0755, IsDir_: true} + if !de.IsDir() { + t.Error("IsDir should be true") + } + if de.Type() != os.ModeDir { + t.Errorf("Type = %v, want ModeDir", de.Type()) + } +} + +func TestFileEntryAndDirEntry(t *testing.T) { + fe := FileEntry("myfile", 0644) + if fe.Name() != "myfile" || fe.IsDir() { + t.Error("FileEntry wrong") + } + + de := DirEntry("mydir", 0755) + if de.Name() != "mydir" || !de.IsDir() { + t.Error("DirEntry wrong") + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - delete/rename operations +// ══════════════════════════════════════════════════════════════════ + +func TestDeleteDir(t *testing.T) { + var deleted bool + spec := DirNode[TestCtx]("/", + Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return []Binding[TestCtx]{{Name: "item"}}, nil + }, + FileNode[TestCtx]("data", 0444), + RemoveNode[TestCtx](func(ctx TestCtx) error { + deleted = true + return nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + err := tree.Delete("item") + if err != nil { + t.Fatalf("Delete: %v", err) + } + if !deleted { + t.Error("remove handler not called") + } +} + +func TestDeleteDirNotFound(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444), + ) + tree := BuildTree(spec, TestCtx{}) + err := tree.Delete("nonexistent") + if err == nil { + t.Error("Delete should fail for nonexistent") + } +} + +func TestDeleteDirNotRemovable(t *testing.T) { + spec := DirNode[TestCtx]("/", + Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return []Binding[TestCtx]{{Name: "item"}}, nil + }, + FileNode[TestCtx]("data", 0444), + // No RemoveNode set + ), + ) + tree := BuildTree(spec, TestCtx{}) + err := tree.Delete("item") + if err == nil { + t.Error("Delete should fail when not removable") + } +} + +func TestRenameDir(t *testing.T) { + var newName string + spec := DirNode[TestCtx]("/", + Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return []Binding[TestCtx]{{ + Name: "item", + Rename: func(n string) error { + newName = n + return nil + }, + }}, nil + }, + FileNode[TestCtx]("data", 0444), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + err := tree.Rename("item", "newitem") + if err != nil { + t.Fatalf("Rename: %v", err) + } + if newName != "newitem" { + t.Errorf("newName = %q, want 'newitem'", newName) + } +} + +func TestRenameDirNotFound(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444), + ) + tree := BuildTree(spec, TestCtx{}) + err := tree.Rename("nonexistent", "new") + if err == nil { + t.Error("Rename should fail for nonexistent") + } +} + +func TestRenameDirNotSupported(t *testing.T) { + spec := DirNode[TestCtx]("/", + Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return []Binding[TestCtx]{{Name: "item"}}, nil // No Rename handler + }, + FileNode[TestCtx]("data", 0444), + ), + ) + tree := BuildTree(spec, TestCtx{}) + err := tree.Rename("item", "new") + if err == nil { + t.Error("Rename should fail when not supported") + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - blocking modes in openLeaf +// ══════════════════════════════════════════════════════════════════ + +func TestOpenLeafBlockOnce(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("blocking", 0444, + BlockOnce(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { + return []byte("data"), "next", nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("blocking") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !f.BlockingReadMode() { + t.Error("expected BlockingReadMode") + } + if f.StreamMode() || f.RequestRespMode() { + t.Error("other modes should be false") + } +} + +func TestOpenLeafStream(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("streaming", 0444, + Stream(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { + return []byte("stream"), "cursor", nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("streaming") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !f.StreamMode() { + t.Error("expected StreamMode") + } + if f.BlockingReadMode() || f.RequestRespMode() { + t.Error("other modes should be false") + } +} + +func TestOpenLeafRequest(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("request", 0666, + Request(func(ctx TestCtx, data []byte) ([]byte, error) { + return append([]byte("echo:"), data...), nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("request") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !f.RequestRespMode() { + t.Error("expected RequestRespMode") + } + + // Write then read + err = f.Write([]byte("test")) + if err != nil { + t.Fatalf("Write: %v", err) + } + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "echo:test" { + t.Errorf("data = %q, want 'echo:test'", data) + } +} + +func TestOpenLeafRequestError(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("request", 0666, + Request(func(ctx TestCtx, data []byte) ([]byte, error) { + return nil, errors.New("request error") + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("request") + if err != nil { + t.Fatalf("Open: %v", err) + } + err = f.Write([]byte("test")) + if err == nil { + t.Error("Write should fail when request returns error") + } +} + +func TestOpenLeafRequestWithFallbackRead(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("request", 0666, + Read(func(ctx TestCtx) ([]byte, error) { + return []byte("fallback"), nil + }), + Request(func(ctx TestCtx, data []byte) ([]byte, error) { + return []byte("response"), nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("request") + if err != nil { + t.Fatalf("Open: %v", err) + } + + // Read without write should use fallback + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "fallback" { + t.Errorf("data = %q, want 'fallback'", data) + } +} + +func TestOpenLeafNoWriteHandler(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("readonly", 0444, + Read(func(ctx TestCtx) ([]byte, error) { + return []byte("data"), nil + }), + // No Write handler + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("readonly") + if err != nil { + t.Fatalf("Open: %v", err) + } + err = f.Write([]byte("test")) + if err == nil { + t.Error("Write should fail on read-only file") + } +} + +func TestOpenLeafNoReadHandler(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("noread", 0222, + Write(func(ctx TestCtx, data []byte) error { + return nil + }), + // No Read handler + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("noread") + if err != nil { + t.Fatalf("Open: %v", err) + } + // Read should return empty + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if len(data) != 0 { + t.Errorf("data = %q, want empty", data) + } +} + +func TestOpenLeafStatWithCustomStat(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("custom", 0444, + StatOverride[TestCtx](func() os.FileInfo { + return &SyntheticFileInfo{Name_: "customname", Size_: 999, Mode_: 0777} + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("custom") + if err != nil { + t.Fatalf("Open: %v", err) + } + fi, err := f.Stat() + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.Name() != "customname" { + t.Errorf("Name = %q", fi.Name()) + } + if fi.Size() != 999 { + t.Errorf("Size = %d", fi.Size()) + } +} + +func TestOpenLeafDefaultBlockingRead(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("normal", 0444, + Read(func(ctx TestCtx) ([]byte, error) { + return []byte("data"), nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + f, err := tree.Open("normal") + if err != nil { + t.Fatalf("Open: %v", err) + } + // BlockingRead should return error for non-blocking file + _, _, err = f.BlockingRead(context.Background(), "") + if err == nil { + t.Error("BlockingRead should fail for non-blocking file") + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - validation +// ══════════════════════════════════════════════════════════════════ + +func TestValidateDirWithFileHandlers(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("should panic for dir with file handlers") + } + }() + + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("child", 0444), + Read(func(ctx TestCtx) ([]byte, error) { return nil, nil }), + ) + BuildTree(spec, TestCtx{}) +} + +func TestValidateMultipleBlockingModes(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("should panic for multiple blocking modes") + } + }() + + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444, + BlockOnce(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { return nil, "", nil }), + Stream(func(ctx TestCtx, cctx context.Context, base string) ([]byte, string, error) { return nil, "", nil }), + ), + ) + BuildTree(spec, TestCtx{}) +} + +func TestValidateCreateOnNonDir(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("should panic for Create on non-dir") + } + }() + + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444, + CreateFile(func(ctx TestCtx, name string) error { return nil }), + ), + ) + BuildTree(spec, TestCtx{}) +} + +func TestValidateTemplateWithoutBindings(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("should panic for template without bindings") + } + }() + + spec := DirNode[TestCtx]("/", + DirNode[TestCtx]("{id}", + FileNode[TestCtx]("data", 0444), + ), + ) + BuildTree(spec, TestCtx{}) +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - findChild with nested paths +// ══════════════════════════════════════════════════════════════════ + +func TestFindChildNestedPath(t *testing.T) { + spec := DirNode[TestCtx]("/", + DirNode[TestCtx]("level1", + DirNode[TestCtx]("level2", + FileNode[TestCtx]("file", 0444, + Read(func(ctx TestCtx) ([]byte, error) { + return []byte("deep"), nil + }), + ), + ), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // Open nested file + f, err := tree.Open("level1/level2/file") + if err != nil { + t.Fatalf("Open: %v", err) + } + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "deep" { + t.Errorf("data = %q, want 'deep'", data) + } +} + +func TestFindChildNestedNotFound(t *testing.T) { + spec := DirNode[TestCtx]("/", + DirNode[TestCtx]("level1", + FileNode[TestCtx]("file", 0444), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + _, err := tree.Open("level1/nonexistent/file") + if err == nil { + t.Error("Open should fail for nonexistent nested path") + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - Each with file handlers (file template) +// ══════════════════════════════════════════════════════════════════ + +func TestEachFileTemplate(t *testing.T) { + items := map[string]string{ + "item1": "content1", + "item2": "content2", + } + + spec := DirNode[TestCtx]("/", + Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + var bindings []Binding[TestCtx] + for name, content := range items { + c := content // capture + bindings = append(bindings, Binding[TestCtx]{ + Name: name, + Applier: func(ctx TestCtx) TestCtx { + ctx.Data = c + return ctx + }, + }) + } + return bindings, nil + }, + Read(func(ctx TestCtx) ([]byte, error) { + return []byte(ctx.Data.(string)), nil + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // List should show files + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 2 { + t.Errorf("entries = %d, want 2", len(entries)) + } + for _, e := range entries { + if e.IsDir() { + t.Errorf("%s should be a file", e.Name()) + } + } + + // Open and read + f, err := tree.Open("item1") + if err != nil { + t.Fatalf("Open: %v", err) + } + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "content1" { + t.Errorf("data = %q, want 'content1'", data) + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - Each bindings error +// ══════════════════════════════════════════════════════════════════ + +func TestEachBindingsError(t *testing.T) { + spec := DirNode[TestCtx]("/", + Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { + return nil, errors.New("bindings error") + }, + FileNode[TestCtx]("data", 0444), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // List should return empty (error is swallowed) + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 0 { + t.Errorf("entries = %d, want 0", len(entries)) + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - statDir +// ══════════════════════════════════════════════════════════════════ + +func TestStatDirSelf(t *testing.T) { + spec := DirNode[TestCtx]("/", + DirNode[TestCtx]("subdir", + FileNode[TestCtx]("file", 0444), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // Stat directory itself + fi, err := tree.Stat("") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if !fi.IsDir() { + t.Error("should be dir") + } + + // Stat with "." + fi2, err := tree.Stat(".") + if err != nil { + // This might not be supported, which is OK + return + } + if !fi2.IsDir() { + t.Error("should be dir") + } +} + +func TestStatDirChild(t *testing.T) { + spec := DirNode[TestCtx]("/", + DirNode[TestCtx]("subdir", + FileNode[TestCtx]("file", 0444), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // Stat child directory + fi, err := tree.Stat("subdir") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if !fi.IsDir() { + t.Error("should be dir") + } + if fi.Name() != "subdir" { + t.Errorf("Name = %q", fi.Name()) + } +} + +func TestStatDirFile(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("myfile", 0644), + ) + + tree := BuildTree(spec, TestCtx{}) + + fi, err := tree.Stat("myfile") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.IsDir() { + t.Error("should not be dir") + } + if fi.Name() != "myfile" { + t.Errorf("Name = %q", fi.Name()) + } +} + +func TestStatDirWithStatOverride(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("custom", 0444, + StatOverride[TestCtx](func() os.FileInfo { + return &SyntheticFileInfo{Name_: "override", Size_: 123} + }), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + fi, err := tree.Stat("custom") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.Name() != "override" { + t.Errorf("Name = %q", fi.Name()) + } + if fi.Size() != 123 { + t.Errorf("Size = %d", fi.Size()) + } +} + +func TestStatDirNotFound(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444), + ) + + tree := BuildTree(spec, TestCtx{}) + + _, err := tree.Stat("nonexistent") + if err == nil { + t.Error("Stat should fail for nonexistent") + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - create not supported +// ══════════════════════════════════════════════════════════════════ + +func TestCreateNotSupported(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444), + // No CreateFile handler + ) + + tree := BuildTree(spec, TestCtx{}) + err := tree.Create("newfile") + if err == nil { + t.Error("Create should fail when not supported") + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - UID/GID inheritance +// ══════════════════════════════════════════════════════════════════ + +func TestUIDGIDInheritance(t *testing.T) { + spec := DirNode[TestCtx]("/", + UID[TestCtx]("rootuser"), + GID[TestCtx]("rootgroup"), + DirNode[TestCtx]("sub", + // Should inherit from parent + FileNode[TestCtx]("file", 0444), + ), + DirNode[TestCtx]("sub2", + UID[TestCtx]("subuser"), + FileNode[TestCtx]("file2", 0444, + UID[TestCtx]("fileuser"), // Explicitly set UID on file + ), + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // Check inherited values - sub/file inherits from root + f, err := tree.Open("sub/file") + if err != nil { + t.Fatalf("Open: %v", err) + } + fi, err := f.Stat() + if err != nil { + t.Fatalf("Stat: %v", err) + } + sfi := fi.(*SyntheticFileInfo) + if sfi.UID_ != "rootuser" { + t.Errorf("UID = %q, want 'rootuser'", sfi.UID_) + } + if sfi.GID_ != "rootgroup" { + t.Errorf("GID = %q, want 'rootgroup'", sfi.GID_) + } + + // Check overridden UID on file + f2, err := tree.Open("sub2/file2") + if err != nil { + t.Fatalf("Open: %v", err) + } + fi2, err := f2.Stat() + if err != nil { + t.Fatalf("Stat: %v", err) + } + sfi2 := fi2.(*SyntheticFileInfo) + if sfi2.UID_ != "fileuser" { + t.Errorf("UID = %q, want 'fileuser'", sfi2.UID_) + } + // GID should still be inherited from root + if sfi2.GID_ != "rootgroup" { + t.Errorf("GID = %q, want 'rootgroup'", sfi2.GID_) + } +} + +// ══════════════════════════════════════════════════════════════════ +// Builder tests - mode defaults +// ══════════════════════════════════════════════════════════════════ + +func TestModeDefaults(t *testing.T) { + spec := DirNode[TestCtx]("/", + DirNode[TestCtx]("subdir", + FileNode[TestCtx]("file", 0644), // Explicit mode + ), + ) + + tree := BuildTree(spec, TestCtx{}) + + // Check dir mode - 0 defaults to 0755 + fi, err := tree.Stat("subdir") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.Mode()&os.ModePerm != 0755 { + t.Errorf("dir mode = %o, want 0755", fi.Mode()&os.ModePerm) + } + + // Check file mode - explicit 0644 + fi2, err := tree.Stat("subdir/file") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi2.Mode() != 0644 { + t.Errorf("file mode = %o, want 0644", fi2.Mode()) + } + + // Also test that root dir defaults to 0755 + fi3, err := tree.Stat("") + if err != nil { + t.Fatalf("Stat root: %v", err) + } + if fi3.Mode()&os.ModePerm != 0755 { + t.Errorf("root mode = %o, want 0755", fi3.Mode()&os.ModePerm) + } +} + +// ══════════════════════════════════════════════════════════════════ +// Original tests (preserved) +// ══════════════════════════════════════════════════════════════════ + func TestEachBasic(t *testing.T) { type testData struct { ID string @@ -141,7 +1598,6 @@ func TestEachBasic(t *testing.T) { } } -// TestContextApplier tests that Binding.Applier correctly propagates context func TestContextApplier(t *testing.T) { type Ctx struct { UserID string @@ -201,7 +1657,6 @@ func TestContextApplier(t *testing.T) { } } -// TestGenerateHelp tests help generation func TestGenerateHelp(t *testing.T) { spec := DirNode[TestCtx]("/", FileNode[TestCtx]("version", 0444, @@ -247,7 +1702,6 @@ func containsAt(s, substr string) bool { return false } -// TestStatMode tests file modes are preserved func TestStatMode(t *testing.T) { spec := DirNode[TestCtx]("/", Each("{id}", func(ctx TestCtx) ([]Binding[TestCtx], error) { diff --git a/fsedsl/tree_test.go b/fsedsl/tree_test.go new file mode 100644 index 0000000..004e7ef --- /dev/null +++ b/fsedsl/tree_test.go @@ -0,0 +1,603 @@ +package fsedsl + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// ══════════════════════════════════════════════════════════════════ +// Filesystem-backed Tree tests +// ══════════════════════════════════════════════════════════════════ + +func TestTreeFilesystemOperations(t *testing.T) { + // Create temp directory + tmpDir := t.TempDir() + + // Create a test file + testFile := filepath.Join(tmpDir, "testfile.txt") + if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + tree := NewTree([]string{tmpDir}, 0644) + + // Test resolve (via Stat) + fi, err := tree.Stat("testfile.txt") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.Name() != "testfile.txt" { + t.Errorf("Name = %q", fi.Name()) + } + + // Test List (without listFn, uses os.ReadDir) + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 1 { + t.Errorf("List: got %d entries, want 1", len(entries)) + } + + // Test Open (without openFn, uses file system) + f, err := tree.Open("testfile.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "hello" { + t.Errorf("data = %q, want 'hello'", data) + } + + // Test Write + err = f.Write([]byte("world")) + if err != nil { + t.Fatalf("Write: %v", err) + } + // Verify write + content, _ := os.ReadFile(testFile) + if string(content) != "world" { + t.Errorf("content = %q, want 'world'", content) + } + + // Test Readdir (without readdirFn) + subDir := filepath.Join(tmpDir, "subdir") + if err := os.Mkdir(subDir, 0755); err != nil { + t.Fatalf("Mkdir: %v", err) + } + os.WriteFile(filepath.Join(subDir, "nested.txt"), []byte("nested"), 0644) + + subEntries, err := tree.Readdir("subdir") + if err != nil { + t.Fatalf("Readdir: %v", err) + } + if len(subEntries) != 1 { + t.Errorf("Readdir: got %d entries, want 1", len(subEntries)) + } +} + +func TestTreeCreate(t *testing.T) { + tmpDir := t.TempDir() + tree := NewTree([]string{tmpDir}, 0644) + + // Test Create (without createFn) + err := tree.Create("newfile.txt") + if err != nil { + t.Fatalf("Create: %v", err) + } + + // Verify file exists + _, err = os.Stat(filepath.Join(tmpDir, "newfile.txt")) + if err != nil { + t.Error("Created file should exist") + } + + // Test Create with nested path + err = tree.Create("nested/deep/file.txt") + if err != nil { + t.Fatalf("Create nested: %v", err) + } + _, err = os.Stat(filepath.Join(tmpDir, "nested/deep/file.txt")) + if err != nil { + t.Error("Nested created file should exist") + } +} + +func TestTreeDelete(t *testing.T) { + tmpDir := t.TempDir() + + // Create test file + testFile := filepath.Join(tmpDir, "todelete.txt") + os.WriteFile(testFile, []byte("delete me"), 0644) + + tree := NewTree([]string{tmpDir}, 0644) + + // Test Delete (without deleteFn) + err := tree.Delete("todelete.txt") + if err != nil { + t.Fatalf("Delete: %v", err) + } + + // Verify file is gone + _, err = os.Stat(testFile) + if !os.IsNotExist(err) { + t.Error("Deleted file should not exist") + } +} + +func TestTreeRename(t *testing.T) { + tmpDir := t.TempDir() + + // Create test file + oldFile := filepath.Join(tmpDir, "old.txt") + os.WriteFile(oldFile, []byte("rename me"), 0644) + + tree := NewTree([]string{tmpDir}, 0644) + + // Test Rename (without renameFn) + err := tree.Rename("old.txt", "new.txt") + if err != nil { + t.Fatalf("Rename: %v", err) + } + + // Verify old file is gone + _, err = os.Stat(oldFile) + if !os.IsNotExist(err) { + t.Error("Old file should not exist") + } + + // Verify new file exists + newFile := filepath.Join(tmpDir, "new.txt") + _, err = os.Stat(newFile) + if err != nil { + t.Error("New file should exist") + } +} + +func TestTreeMkdirAll(t *testing.T) { + tmpDir := t.TempDir() + tree := NewTree([]string{tmpDir}, 0755) + + // Test MkdirAll + err := tree.MkdirAll("a/b/c") + if err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Verify directory exists + fi, err := os.Stat(filepath.Join(tmpDir, "a/b/c")) + if err != nil { + t.Fatal("Directory should exist") + } + if !fi.IsDir() { + t.Error("Should be a directory") + } +} + +func TestTreeReadOnly(t *testing.T) { + tmpDir := t.TempDir() + tree := NewTree([]string{tmpDir}, 0644, WithReadOnly()) + + // Create should fail + err := tree.Create("file.txt") + if err == nil { + t.Error("Create should fail on read-only tree") + } + + // Delete should fail + os.WriteFile(filepath.Join(tmpDir, "existing.txt"), []byte("x"), 0644) + err = tree.Delete("existing.txt") + if err == nil { + t.Error("Delete should fail on read-only tree") + } + + // Rename should fail + err = tree.Rename("existing.txt", "new.txt") + if err == nil { + t.Error("Rename should fail on read-only tree") + } + + // MkdirAll should fail + err = tree.MkdirAll("newdir") + if err == nil { + t.Error("MkdirAll should fail on read-only tree") + } + + // Write via Open should fail + f, err := tree.Open("existing.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + err = f.Write([]byte("new content")) + if err == nil { + t.Error("Write should fail on read-only tree") + } +} + +func TestTreeMkdirAllNoBackingDir(t *testing.T) { + tree := NewTree(nil, 0755) + + err := tree.MkdirAll("somedir") + if err == nil { + t.Error("MkdirAll should fail with no backing directory") + } +} + +func TestTreeReaddirNoBackingDir(t *testing.T) { + tree := NewTree(nil, 0755) + + _, err := tree.Readdir("somedir") + if err == nil { + t.Error("Readdir should fail with no backing directory") + } +} + +func TestTreeOpenNotFound(t *testing.T) { + tmpDir := t.TempDir() + tree := NewTree([]string{tmpDir}, 0644, WithReadOnly()) + + // Open non-existent file on read-only tree should fail + _, err := tree.Open("nonexistent.txt") + if err == nil { + t.Error("Open should fail for nonexistent file on read-only tree") + } +} + +func TestTreeOpenNewFile(t *testing.T) { + tmpDir := t.TempDir() + tree := NewTree([]string{tmpDir}, 0644) + + // Open non-existent file on writable tree should work (creates on write) + f, err := tree.Open("newfile.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + + // Write creates the file + err = f.Write([]byte("created")) + if err != nil { + t.Fatalf("Write: %v", err) + } + + // Verify file exists + content, err := os.ReadFile(filepath.Join(tmpDir, "newfile.txt")) + if err != nil { + t.Fatal("File should exist after write") + } + if string(content) != "created" { + t.Errorf("content = %q, want 'created'", content) + } +} + +func TestTreeUnionDirs(t *testing.T) { + // Create two directories + dir1 := t.TempDir() + dir2 := t.TempDir() + + // Put different files in each + os.WriteFile(filepath.Join(dir1, "file1.txt"), []byte("from dir1"), 0644) + os.WriteFile(filepath.Join(dir2, "file2.txt"), []byte("from dir2"), 0644) + // Same file in both - dir1 should win + os.WriteFile(filepath.Join(dir1, "common.txt"), []byte("dir1 version"), 0644) + os.WriteFile(filepath.Join(dir2, "common.txt"), []byte("dir2 version"), 0644) + + tree := NewTree([]string{dir1, dir2}, 0644) + + // List should show all unique files + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + } + if !names["file1.txt"] || !names["file2.txt"] || !names["common.txt"] { + t.Errorf("Missing expected files, got: %v", names) + } + + // Resolve common.txt should return dir1 version + f, err := tree.Open("common.txt") + if err != nil { + t.Fatalf("Open: %v", err) + } + data, err := f.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data) != "dir1 version" { + t.Errorf("data = %q, want 'dir1 version'", data) + } + + // Resolve file2.txt from dir2 + f2, err := tree.Open("file2.txt") + if err != nil { + t.Fatalf("Open file2: %v", err) + } + data2, err := f2.Read() + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(data2) != "from dir2" { + t.Errorf("data2 = %q, want 'from dir2'", data2) + } +} + +func TestTreeChildDelegation(t *testing.T) { + tmpDir := t.TempDir() + parentDir := t.TempDir() + + // Create file in child + os.WriteFile(filepath.Join(tmpDir, "childfile.txt"), []byte("child content"), 0644) + + parent := NewTree([]string{parentDir}, 0755) + child := NewTree([]string{tmpDir}, 0644) + parent.Mount("sub", child) + + // Operations should delegate to child + + // Create in child + err := parent.Create("sub/newfile.txt") + if err != nil { + t.Fatalf("Create in child: %v", err) + } + _, err = os.Stat(filepath.Join(tmpDir, "newfile.txt")) + if err != nil { + t.Error("File should exist in child dir") + } + + // Delete in child + err = parent.Delete("sub/newfile.txt") + if err != nil { + t.Fatalf("Delete in child: %v", err) + } + + // Rename in child + err = parent.Rename("sub/childfile.txt", "renamed.txt") + if err != nil { + t.Fatalf("Rename in child: %v", err) + } + + // Readdir via child (rest != "") + subDir := filepath.Join(tmpDir, "nested") + os.Mkdir(subDir, 0755) + os.WriteFile(filepath.Join(subDir, "deep.txt"), []byte("deep"), 0644) + + entries, err := parent.Readdir("sub/nested") + if err != nil { + t.Fatalf("Readdir nested: %v", err) + } + if len(entries) != 1 { + t.Errorf("Readdir: got %d, want 1", len(entries)) + } + + // MkdirAll in child + err = parent.MkdirAll("sub/new/deep/path") + if err != nil { + t.Fatalf("MkdirAll in child: %v", err) + } + _, err = os.Stat(filepath.Join(tmpDir, "new/deep/path")) + if err != nil { + t.Error("Directory should exist in child") + } + + // Stat child directory itself + fi, err := parent.Stat("sub") + if err != nil { + t.Fatalf("Stat child: %v", err) + } + if !fi.IsDir() { + t.Error("Child should be dir") + } + + // Stat nested path in child + fi2, err := parent.Stat("sub/renamed.txt") + if err != nil { + t.Fatalf("Stat in child: %v", err) + } + if fi2.IsDir() { + t.Error("File should not be dir") + } +} + +func TestTreeOpenChildDir(t *testing.T) { + parent := NewTree(nil, 0755) + child := NewTree(nil, 0755) + parent.Mount("sub", child) + + // Opening a directory should fail + _, err := parent.Open("sub") + if err == nil { + t.Error("Open directory should fail") + } +} + +func TestTreeStatWithOpenerFallback(t *testing.T) { + tree := NewTree(nil, 0755, + WithOpener(func(dirs []string, name string) (File, error) { + return &FileConfig{ + StatFn: func() (os.FileInfo, error) { + return &SyntheticFileInfo{Name_: name, Mode_: 0600, Size_: 42}, nil + }, + ReadFn: func() ([]byte, error) { return []byte("content"), nil }, + WriteFn: func([]byte) error { return nil }, + BlockingReadFn: func(context.Context, string) ([]byte, string, error) { return nil, "", nil }, + }, nil + }), + ) + + // Stat should use opener to get file info + fi, err := tree.Stat("anyfile") + if err != nil { + t.Fatalf("Stat: %v", err) + } + if fi.Name() != "anyfile" { + t.Errorf("Name = %q", fi.Name()) + } + if fi.Size() != 42 { + t.Errorf("Size = %d, want 42", fi.Size()) + } +} + +func TestTreeStatWithResolverNameOverride(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "realfile.txt"), []byte("content"), 0644) + + tree := NewTree([]string{tmpDir}, 0755, + WithResolver(func(dirs []string, name string) (string, error) { + // Map virtual name to real file + if name == "virtual.txt" { + return filepath.Join(dirs[0], "realfile.txt"), nil + } + return "", os.ErrNotExist + }), + ) + + // Stat via resolver should use synthetic info with original name + fi, err := tree.Stat("virtual.txt") + if err != nil { + t.Fatalf("Stat: %v", err) + } + // Name should be the virtual name, not the real filename + if fi.Name() != "virtual.txt" { + t.Errorf("Name = %q, want 'virtual.txt'", fi.Name()) + } +} + +func TestTreeListWithIndexAndChildren(t *testing.T) { + tmpDir := t.TempDir() + os.WriteFile(filepath.Join(tmpDir, "file.txt"), []byte("x"), 0644) + + tree := NewTree([]string{tmpDir}, 0755, + WithIndex(func(ft FileTree) ([]byte, error) { + return []byte("index"), nil + }), + ) + + child := NewTree(nil, 0755) + tree.Mount("child", child) + + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + } + + if !names["idx"] { + t.Error("Missing idx") + } + if !names["file.txt"] { + t.Error("Missing file.txt") + } + if !names["child"] { + t.Error("Missing child") + } +} + +func TestTreeListWithListerAndIndex(t *testing.T) { + tree := NewTree(nil, 0755, + WithLister(func(dirs []string) ([]os.DirEntry, error) { + return []os.DirEntry{FileEntry("synthetic", 0644)}, nil + }), + WithIndex(func(ft FileTree) ([]byte, error) { + return []byte("index"), nil + }), + ) + + child := NewTree(nil, 0755) + tree.Mount("mounted", child) + + entries, err := tree.List() + if err != nil { + t.Fatalf("List: %v", err) + } + + names := make(map[string]bool) + for _, e := range entries { + names[e.Name()] = true + } + + if !names["idx"] { + t.Error("Missing idx (should be prepended)") + } + if !names["synthetic"] { + t.Error("Missing synthetic") + } + if !names["mounted"] { + t.Error("Missing mounted child") + } +} + +func TestValidateReadModeAllThree(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("should panic with all three modes set") + } + }() + + fc := &FileConfig{ + BlockingRead_: true, + StreamMode_: true, + RequestResp_: true, + } + fc.ValidateReadMode() +} + +// Test readdirDir error path +func TestReaddirDirNotFound(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("file", 0444), + ) + + tree := BuildTree(spec, TestCtx{}) + _, err := tree.Readdir("nonexistent") + if err == nil { + t.Error("Readdir should fail for nonexistent") + } +} + +// Test help.go edge cases +func TestGenerateHelpModes(t *testing.T) { + spec := DirNode[TestCtx]("/", + FileNode[TestCtx]("readonly", 0444, + Read(func(ctx TestCtx) ([]byte, error) { return nil, nil }), + Doc[TestCtx]("Read-only file"), + ), + FileNode[TestCtx]("writeonly", 0222, + Write(func(ctx TestCtx, data []byte) error { return nil }), + Doc[TestCtx]("Write-only file"), + ), + FileNode[TestCtx]("executable", 0755, + Doc[TestCtx]("Executable file"), + ), + DirNode[TestCtx]("subdir", + Doc[TestCtx]("A subdirectory"), + FileNode[TestCtx]("nested", 0444), + ), + ) + + help := GenerateHelp(spec) + + if !contains(help, "readonly") { + t.Error("Missing readonly in help") + } + if !contains(help, "writeonly") { + t.Error("Missing writeonly in help") + } + if !contains(help, "subdir") { + t.Error("Missing subdir in help") + } + // fmtMode produces 4-char strings like "-rr-", "-w--", "drwx" + if !contains(help, "-rr-") && !contains(help, "-w--") { + t.Errorf("Missing mode indicators in help:\n%s", help) + } +}