Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions internal/sync/detach/detach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package detach

import (
"errors"
"fmt"
"io"
"path"
"slices"
"strings"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
synclocal "github.com/launchdarkly/ldcli/internal/sync/local"
syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest"
)

// Resource identifies one local or manifested resource that can be detached.
type Resource = syncdomain.ResourceID

// Options contains the local stores and streams used by detach.
type Options struct {
RepositoryRoot string
Store synclocal.Store
Manifest syncmanifest.Store
Input io.Reader
Output io.Writer
}

// Run lets the user select resources and removes their local sync state.
func Run(options Options) error {
resources, manifest, manifestExists, err := loadResources(options.RepositoryRoot, options.Manifest)
if err != nil {
return err
}
if len(resources) == 0 {
_ = syncconsole.New(options.Output).Line("No resources are currently synced.")
return nil
}
if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) {
return fmt.Errorf("interactive resource selection requires a terminal; run this command in a terminal")
}

choices := make([]syncinteractive.Choice[Resource], 0, len(resources))
for _, resource := range resources {
choices = append(choices, syncinteractive.Choice[Resource]{
Title: resource.ProjectKey + "/" + resource.LookupKey,
Description: string(resource.Kind),
Value: resource,
})
}
selected, canceled, err := syncinteractive.MultiSelect(
options.Input,
options.Output,
"Select resources to detach",
"Detached resources remain in LaunchDarkly.",
choices,
)
if err != nil {
return err
}
if canceled {
return nil
}
if err := detachResources(options, manifest, manifestExists, selected); err != nil {
return err
}

console := syncconsole.New(options.Output)
_ = console.Line("Detached resources:")
for _, resource := range selected {
_ = console.Printf("- %s %s/%s\n", resource.Kind, resource.ProjectKey, resource.LookupKey)
}
return nil
}

// loadResources returns the union of local wrappers and manifested resources.
func loadResources(repositoryRoot string, manifestStore syncmanifest.Store) ([]Resource, syncmanifest.Manifest, bool, error) {
manifest, manifestExists, err := manifestStore.Load()
if err != nil {
return nil, syncmanifest.Manifest{}, false, err
}

resources := make(map[Resource]struct{}, len(manifest.Resources))
for _, resource := range manifest.Resources {
resources[resource.ID()] = struct{}{}
}

files, err := synclocal.SourceFiles(repositoryRoot)
if err != nil {
return nil, syncmanifest.Manifest{}, false, err
}
for _, file := range files {
resource, ok := resourceFromWrapperPath(file)
if ok {
resources[resource] = struct{}{}
}
}

result := make([]Resource, 0, len(resources))
for resource := range resources {
result = append(result, resource)
}
slices.SortFunc(result, syncdomain.CompareResourceIDs)
return result, manifest, manifestExists, nil
}

// resourceFromWrapperPath derives a variation identity without parsing its contents.
func resourceFromWrapperPath(file string) (Resource, bool) {
parts := strings.Split(file, "/")
if len(parts) != 5 || parts[0] != syncdomain.RootDir || parts[2] != "configs" || !strings.HasSuffix(parts[4], ".prompt.md") {
return Resource{}, false
}
variationKey := strings.TrimSuffix(parts[4], ".prompt.md")
if parts[1] == "" || parts[3] == "" || variationKey == "" {
return Resource{}, false
}
return Resource{Kind: syncdomain.KindVariation, ProjectKey: parts[1], LookupKey: path.Join(parts[3], variationKey)}, true
}

// detachResources removes selected resources from the manifest before deleting local wrappers.
func detachResources(options Options, original syncmanifest.Manifest, manifestExists bool, selected []Resource) error {
selectedSet := make(map[Resource]struct{}, len(selected))
for _, resource := range selected {
selectedSet[resource] = struct{}{}
}

updated := syncmanifest.New()
for _, resource := range original.Resources {
if _, detach := selectedSet[resource.ID()]; !detach {
updated.Resources = append(updated.Resources, resource)
}
}
if err := options.Manifest.Write(updated); err != nil {
return err
}

var deletions []synclocal.VariationDeletion
for _, resource := range selected {
if resource.Kind != syncdomain.KindVariation {
continue
}
configKey, variationKey, ok := strings.Cut(resource.LookupKey, "/")
if !ok || strings.Contains(variationKey, "/") {
continue
}
exists, err := options.Store.VariationExists(resource.ProjectKey, configKey, variationKey)
if err != nil {
return errors.Join(err, restoreManifest(options.Manifest, original, manifestExists))
}
if exists {
deletions = append(deletions, synclocal.VariationDeletion{
ProjectKey: resource.ProjectKey, ConfigKey: configKey, VariationKey: variationKey,
})
}
}

if _, err := options.Store.DeleteVariations(deletions); err != nil {
return errors.Join(err, restoreManifest(options.Manifest, original, manifestExists))
}
if err := options.Store.RemoveEmptyDirectories(); err != nil {
return err
}
return nil
}

// restoreManifest restores the manifest when local wrapper deletion fails.
func restoreManifest(store syncmanifest.Store, manifest syncmanifest.Manifest, existed bool) error {
if existed {
return store.Write(manifest)
}
return store.Remove()
}
175 changes: 175 additions & 0 deletions internal/sync/detach/detach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package detach

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
synclocal "github.com/launchdarkly/ldcli/internal/sync/local"
syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest"
syncreference "github.com/launchdarkly/ldcli/internal/sync/reference"
)

func TestLoadResourcesUnionsLocalAndManifestResources(t *testing.T) {
root := t.TempDir()
store := synclocal.NewStore(root)
_, err := store.Add([]synclocal.VariationFile{{
ProjectKey: "project", ConfigKey: "config", Variation: testVariation("local"),
}})
require.NoError(t, err)

manifestStore := syncmanifest.NewStore(root)
require.NoError(t, manifestStore.Write(syncmanifest.Manifest{
FormatVersion: syncmanifest.FormatVersion,
Resources: []syncmanifest.Resource{{
ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/manifest-only",
Fingerprint: testFingerprint(),
}},
}))

resources, _, exists, err := loadResources(root, manifestStore)

require.NoError(t, err)
require.True(t, exists)
assert.Equal(t, []Resource{
{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/local"},
{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/manifest-only"},
}, resources)
}

func TestDetachResourcesRemovesWrapperAndManifestButKeepsReferencedFile(t *testing.T) {
root := t.TempDir()
referencePath := filepath.Join(root, "prompts", "prompt.md")
require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755))
require.NoError(t, os.WriteFile(referencePath, []byte("Keep me.\n"), 0o644))

store := synclocal.NewStore(root)
variation := testVariation("prompt")
_, err := store.Add([]synclocal.VariationFile{{
ProjectKey: "project", ConfigKey: "config", Variation: variation,
Ref: &synclocal.Reference{File: "prompts/prompt.md", Format: syncreference.PlainMarkdown},
}})
require.NoError(t, err)

manifestStore := syncmanifest.NewStore(root)
original := syncmanifest.Manifest{
FormatVersion: syncmanifest.FormatVersion,
Resources: []syncmanifest.Resource{{
ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt",
Fingerprint: testFingerprint(),
}},
}
require.NoError(t, manifestStore.Write(original))

resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"}
err = detachResources(Options{Store: store, Manifest: manifestStore}, original, true, []Resource{resource})

require.NoError(t, err)
exists, err := store.VariationExists("project", "config", "prompt")
require.NoError(t, err)
assert.False(t, exists)
_, err = os.Stat(referencePath)
require.NoError(t, err)
manifest, exists, err := manifestStore.Load()
require.NoError(t, err)
require.True(t, exists)
assert.Empty(t, manifest.Resources)
}

func TestDetachResourcesRemovesManifestEntryWhenWrapperWasAlreadyDeleted(t *testing.T) {
root := t.TempDir()
store := synclocal.NewStore(root)
manifestStore := syncmanifest.NewStore(root)
original := syncmanifest.Manifest{
FormatVersion: syncmanifest.FormatVersion,
Resources: []syncmanifest.Resource{{
ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/deleted",
Fingerprint: testFingerprint(),
}},
}
require.NoError(t, manifestStore.Write(original))

resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/deleted"}
err := detachResources(Options{Store: store, Manifest: manifestStore}, original, true, []Resource{resource})

require.NoError(t, err)
manifest, exists, err := manifestStore.Load()
require.NoError(t, err)
require.True(t, exists)
assert.Empty(t, manifest.Resources)
}

func TestDetachResourcesDeletesUnreadableWrapper(t *testing.T) {
root := t.TempDir()
wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "broken.prompt.md")
require.NoError(t, os.MkdirAll(filepath.Dir(wrapper), 0o755))
require.NoError(t, os.WriteFile(wrapper, []byte("not front matter"), 0o644))

store := synclocal.NewStore(root)
manifestStore := syncmanifest.NewStore(root)
resource := Resource{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/broken"}

err := detachResources(
Options{Store: store, Manifest: manifestStore},
syncmanifest.New(),
false,
[]Resource{resource},
)

require.NoError(t, err)
manifest, exists, loadErr := manifestStore.Load()
require.NoError(t, loadErr)
require.True(t, exists)
assert.Empty(t, manifest.Resources)
_, statErr := os.Stat(wrapper)
require.ErrorIs(t, statErr, os.ErrNotExist)
}

func TestRunRequiresTerminalWhenResourcesExist(t *testing.T) {
root := t.TempDir()
store := synclocal.NewStore(root)
_, err := store.Add([]synclocal.VariationFile{{
ProjectKey: "project", ConfigKey: "config", Variation: testVariation("prompt"),
}})
require.NoError(t, err)

err = Run(Options{
RepositoryRoot: root,
Store: store,
Manifest: syncmanifest.NewStore(root),
Input: bytes.NewBuffer(nil),
Output: bytes.NewBuffer(nil),
})

require.ErrorContains(t, err, "requires a terminal")
}

func TestRunReportsWhenNoResourcesAreSynced(t *testing.T) {
root := t.TempDir()
var output bytes.Buffer

err := Run(Options{
RepositoryRoot: root,
Store: synclocal.NewStore(root),
Manifest: syncmanifest.NewStore(root),
Input: bytes.NewBuffer(nil),
Output: &output,
})

require.NoError(t, err)
assert.Equal(t, "No resources are currently synced.\n", output.String())
}

func testVariation(key string) syncdomain.Variation {
return syncdomain.Variation{Mode: syncdomain.VariationModeAgent, Key: key, Name: key, Instructions: "Help."}
}

func testFingerprint() string {
return "sha256:" + strings.Repeat("0", 64)
}
Loading
Loading