From 3647be2068afe596ef5ffb3e4b638de681520df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Hermann?= Date: Thu, 2 Apr 2026 19:42:44 +0200 Subject: [PATCH] feat: review workstation setup sources --- cmd/cloudstic/cmd_setup.go | 235 +++++++++++++++++++++- cmd/cloudstic/cmd_setup_test.go | 73 ++++++- internal/engine/workstation_setup.go | 39 ++-- internal/engine/workstation_setup_test.go | 27 ++- 4 files changed, 348 insertions(+), 26 deletions(-) diff --git a/cmd/cloudstic/cmd_setup.go b/cmd/cloudstic/cmd_setup.go index df52409..50e9b56 100644 --- a/cmd/cloudstic/cmd_setup.go +++ b/cmd/cloudstic/cmd_setup.go @@ -127,6 +127,25 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { return r.writeJSON(plan) } + if !args.dryRun && !args.yes { + if !r.canPrompt() { + return r.fail("setup workstation requires an interactive terminal or -yes") + } + if err := reviewWorkstationPlan(ctx, cfg, (*engine.WorkstationSetupPlan)(plan), workstationReviewPrompts{ + confirm: func(ctx context.Context, label string, defaultYes bool) (bool, error) { + return r.promptConfirm(ctx, label, defaultYes) + }, + selectOne: func(ctx context.Context, label string, options []string) (string, error) { + return r.promptSelect(ctx, label, options) + }, + input: func(ctx context.Context, label, defaultValue string, validate func(string) error) (string, error) { + return r.promptValidatedLine(ctx, label, defaultValue, validate) + }, + }); err != nil { + return r.fail("Failed to review workstation setup: %v", err) + } + } + printWorkstationSetupPlan(r.out, plan, args.dryRun) if args.dryRun { return 0 @@ -135,14 +154,11 @@ func (r *runner) runSetupWorkstation(ctx context.Context) int { if plan.StoreRef == "" { return r.fail("Store selection is still unresolved; pass -store-ref or rerun interactively") } - if len(plan.Profiles) == 0 { + if countSelectedWorkstationProfiles(plan) == 0 { _, _ = fmt.Fprintln(r.out, "\nNothing to save.") return 0 } if !args.yes { - if !r.canPrompt() { - return r.fail("setup workstation requires an interactive terminal or -yes") - } ok, err := r.promptConfirm(ctx, "Save workstation setup?", true) if err != nil { return r.fail("Failed to confirm workstation setup: %v", err) @@ -192,7 +208,7 @@ func printWorkstationSetupPlan(out io.Writer, plan *cloudstic.WorkstationSetupPl profile.SourceURI, firstNonEmptyCLI(profile.StoreRef, "(none)"), strings.Join(profile.Tags, ","), - profile.Action, + workstationDraftDecisionLabel(profile), }) } t.Render() @@ -228,3 +244,212 @@ func firstNonEmptyCLI(values ...string) string { } return "" } + +type workstationReviewPrompts struct { + confirm func(context.Context, string, bool) (bool, error) + selectOne func(context.Context, string, []string) (string, error) + input func(context.Context, string, string, func(string) error) (string, error) +} + +func reviewWorkstationPlan(ctx context.Context, cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, prompts workstationReviewPrompts) error { + if plan == nil { + return nil + } + for i := range plan.Profiles { + draft := &plan.Profiles[i] + switch draft.Action { + case "create": + ok, err := prompts.confirm(ctx, fmt.Sprintf("Create profile %q for %s?", draft.Name, draft.DisplayLabel), true) + if err != nil { + return err + } + draft.Selected = ok + case "update": + choice, err := prompts.selectOne(ctx, + fmt.Sprintf("Profile %q already exists for %s", draft.Name, draft.DisplayLabel), + []string{ + fmt.Sprintf("Update existing profile %q", draft.Name), + "Create renamed profile", + "Skip this source", + }, + ) + if err != nil { + return err + } + switch choice { + case fmt.Sprintf("Update existing profile %q", draft.Name): + draft.Selected = true + case "Create renamed profile": + name, err := promptWorkstationProfileName(ctx, prompts, cfg, plan, i, nextAvailableWorkstationProfileName(cfg, plan, draft.Name)) + if err != nil { + return err + } + draft.Name = name + draft.Action = "rename" + draft.Selected = true + default: + draft.Selected = false + draft.Action = "skip" + } + case "rename": + choice, err := prompts.selectOne(ctx, + fmt.Sprintf("Profile name collision for %s", draft.DisplayLabel), + []string{ + fmt.Sprintf("Create renamed profile %q", draft.Name), + "Use a different name", + "Skip this source", + }, + ) + if err != nil { + return err + } + switch choice { + case fmt.Sprintf("Create renamed profile %q", draft.Name): + draft.Selected = true + case "Use a different name": + name, err := promptWorkstationProfileName(ctx, prompts, cfg, plan, i, draft.Name) + if err != nil { + return err + } + draft.Name = name + draft.Selected = true + default: + draft.Selected = false + draft.Action = "skip" + } + default: + draft.Selected = true + } + } + refreshWorkstationCoverage(plan) + return nil +} + +func promptWorkstationProfileName(ctx context.Context, prompts workstationReviewPrompts, cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, index int, defaultName string) (string, error) { + return prompts.input(ctx, "Profile name", defaultName, func(v string) error { + if v == "" { + return fmt.Errorf("profile name is required") + } + if err := validateRefName("profile", v); err != nil { + return err + } + if nameTakenInWorkstationPlan(cfg, plan, index, v) { + return fmt.Errorf("profile %q already exists", v) + } + return nil + }) +} + +func nameTakenInWorkstationPlan(cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, index int, name string) bool { + if existing, ok := cfg.Profiles[name]; ok { + if index >= 0 { + current := plan.Profiles[index] + if current.Action == "update" && current.Name == name && existing.Source == current.SourceURI { + return false + } + } + return true + } + for i, draft := range plan.Profiles { + if i == index || !draft.Selected { + continue + } + if draft.Name == name { + return true + } + } + return false +} + +func nextAvailableWorkstationProfileName(cfg *cloudstic.ProfilesConfig, plan *engine.WorkstationSetupPlan, base string) string { + base = sanitizeWorkstationProfileName(base) + if base == "" { + base = "workstation" + } + candidate := base + for i := 2; ; i++ { + if !nameTakenInWorkstationPlan(cfg, plan, -1, candidate) { + return candidate + } + candidate = fmt.Sprintf("%s-%d", base, i) + } +} + +func refreshWorkstationCoverage(plan *engine.WorkstationSetupPlan) { + if plan == nil { + return + } + profileLabels := map[string]struct{}{} + for _, draft := range plan.Profiles { + if draft.DisplayLabel != "" { + profileLabels[draft.DisplayLabel] = struct{}{} + } + } + + preservedSkipped := make([]string, 0, len(plan.Coverage.SkippedIntentionally)) + for _, item := range plan.Coverage.SkippedIntentionally { + if _, ok := profileLabels[item]; !ok { + preservedSkipped = append(preservedSkipped, item) + } + } + + plan.Coverage.ProtectedNow = nil + plan.Coverage.SkippedIntentionally = preservedSkipped + for _, draft := range plan.Profiles { + label := firstNonEmptyCLI(draft.DisplayLabel, draft.SourceURI) + if draft.Selected { + plan.Coverage.ProtectedNow = append(plan.Coverage.ProtectedNow, label) + } else { + plan.Coverage.SkippedIntentionally = append(plan.Coverage.SkippedIntentionally, label) + } + } +} + +func workstationDraftDecisionLabel(draft cloudstic.WorkstationProfileDraft) string { + if !draft.Selected { + return "skip" + } + return draft.Action +} + +func countSelectedWorkstationProfiles(plan *cloudstic.WorkstationSetupPlan) int { + if plan == nil { + return 0 + } + count := 0 + for _, draft := range plan.Profiles { + if draft.Selected { + count++ + } + } + return count +} + +func sanitizeWorkstationProfileName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "" + } + var b strings.Builder + prevDash := false + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + case r == '.', r == '_', r == '-': + if b.Len() == 0 || prevDash { + continue + } + b.WriteRune(r) + prevDash = r == '-' + default: + if b.Len() == 0 || prevDash { + continue + } + b.WriteRune('-') + prevDash = true + } + } + return strings.Trim(b.String(), "-._") +} diff --git a/cmd/cloudstic/cmd_setup_test.go b/cmd/cloudstic/cmd_setup_test.go index 09f12e2..e727913 100644 --- a/cmd/cloudstic/cmd_setup_test.go +++ b/cmd/cloudstic/cmd_setup_test.go @@ -8,6 +8,7 @@ import ( "testing" cloudstic "github.com/cloudstic/cli" + "github.com/cloudstic/cli/internal/engine" ) func TestRunSetupWorkstation_DryRun(t *testing.T) { @@ -17,7 +18,7 @@ func TestRunSetupWorkstation_DryRun(t *testing.T) { StoreRef: "primary", StoreAction: "use-existing", Profiles: []cloudstic.WorkstationProfileDraft{ - {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create"}, + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create", Selected: true}, }, Coverage: cloudstic.WorkstationCoverageSummary{ ProtectedNow: []string{"Documents (/Users/test/Documents)"}, @@ -71,7 +72,7 @@ func TestRunSetupWorkstation_ApplyYes(t *testing.T) { StoreRef: "primary", StoreAction: "use-existing", Profiles: []cloudstic.WorkstationProfileDraft{ - {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create"}, + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Action: "create", Selected: true}, }, }, } @@ -115,6 +116,74 @@ func TestRunSetupWorkstation_RequiresStoreResolutionWithoutPrompt(t *testing.T) } } +func TestReviewWorkstationPlan_CanSkipSources(t *testing.T) { + cfg := &cloudstic.ProfilesConfig{} + plan := &engine.WorkstationSetupPlan{ + Profiles: []engine.WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", Action: "create", DisplayLabel: "Documents (/Users/test/Documents)", Selected: true}, + }, + Coverage: engine.WorkstationCoverageSummary{ + ProtectedNow: []string{"Documents (/Users/test/Documents)"}, + }, + } + err := reviewWorkstationPlan(context.Background(), cfg, plan, workstationReviewPrompts{ + confirm: func(context.Context, string, bool) (bool, error) { return false, nil }, + selectOne: func(context.Context, string, []string) (string, error) { + t.Fatal("selectOne should not be called") + return "", nil + }, + input: func(context.Context, string, string, func(string) error) (string, error) { + t.Fatal("input should not be called") + return "", nil + }, + }) + if err != nil { + t.Fatalf("reviewWorkstationPlan: %v", err) + } + if plan.Profiles[0].Selected { + t.Fatal("expected draft to be deselected") + } + if !strings.Contains(strings.Join(plan.Coverage.SkippedIntentionally, ","), "Documents (/Users/test/Documents)") { + t.Fatalf("expected skipped coverage to include source: %#v", plan.Coverage) + } +} + +func TestReviewWorkstationPlan_RenameUpdate(t *testing.T) { + cfg := &cloudstic.ProfilesConfig{ + Profiles: map[string]cloudstic.BackupProfile{ + "documents": {Source: "local:/Users/test/Documents"}, + }, + } + plan := &engine.WorkstationSetupPlan{ + Profiles: []engine.WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", Action: "update", DisplayLabel: "Documents (/Users/test/Documents)", Selected: true}, + }, + Coverage: engine.WorkstationCoverageSummary{ + ProtectedNow: []string{"Documents (/Users/test/Documents)"}, + }, + } + var asked bool + err := reviewWorkstationPlan(context.Background(), cfg, plan, workstationReviewPrompts{ + confirm: func(context.Context, string, bool) (bool, error) { return true, nil }, + selectOne: func(context.Context, string, []string) (string, error) { + return "Create renamed profile", nil + }, + input: func(_ context.Context, label, defaultValue string, validate func(string) error) (string, error) { + asked = true + if err := validate("documents-2"); err != nil { + t.Fatalf("validate: %v", err) + } + return "documents-2", nil + }, + }) + if err != nil { + t.Fatalf("reviewWorkstationPlan: %v", err) + } + if !asked || plan.Profiles[0].Name != "documents-2" || plan.Profiles[0].Action != "rename" { + t.Fatalf("unexpected draft after rename: %#v", plan.Profiles[0]) + } +} + func TestDefaultProfilesPathNoCreate(t *testing.T) { configRoot := filepath.Join(t.TempDir(), "config") t.Setenv("CLOUDSTIC_CONFIG_DIR", configRoot) diff --git a/internal/engine/workstation_setup.go b/internal/engine/workstation_setup.go index 6128e61..686ae8e 100644 --- a/internal/engine/workstation_setup.go +++ b/internal/engine/workstation_setup.go @@ -46,11 +46,13 @@ type WorkstationFolderCandidate struct { } type WorkstationProfileDraft struct { - Name string `json:"name"` - SourceURI string `json:"source_uri"` - StoreRef string `json:"store_ref,omitempty"` - Tags []string `json:"tags,omitempty"` - Action string `json:"action"` + Name string `json:"name"` + SourceURI string `json:"source_uri"` + StoreRef string `json:"store_ref,omitempty"` + Tags []string `json:"tags,omitempty"` + Action string `json:"action"` + DisplayLabel string `json:"display_label,omitempty"` + Selected bool `json:"selected"` } type WorkstationCoverageSummary struct { @@ -136,11 +138,13 @@ func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) ( } name, action := nextWorkstationProfileName(cfg, usedNames, folder.Key, hostname, "local:"+folder.Path) plan.Profiles = append(plan.Profiles, WorkstationProfileDraft{ - Name: name, - SourceURI: "local:" + folder.Path, - StoreRef: storeRef, - Tags: []string{"workstation"}, - Action: action, + Name: name, + SourceURI: "local:" + folder.Path, + StoreRef: storeRef, + Tags: []string{"workstation"}, + Action: action, + DisplayLabel: folder.Label + " (" + folder.Path + ")", + Selected: true, }) plan.Coverage.ProtectedNow = append(plan.Coverage.ProtectedNow, folder.Label+" ("+folder.Path+")") } @@ -149,11 +153,13 @@ func PlanWorkstationSetup(ctx context.Context, opts ...WorkstationSetupOption) ( base := sanitizeWorkstationName(firstNonEmpty(src.DriveName, src.DisplayName, filepath.Base(src.MountPoint), "portable")) name, action := nextWorkstationProfileName(cfg, usedNames, base, hostname, src.SourceURI) plan.Profiles = append(plan.Profiles, WorkstationProfileDraft{ - Name: name, - SourceURI: src.SourceURI, - StoreRef: storeRef, - Tags: []string{"portable", "workstation"}, - Action: action, + Name: name, + SourceURI: src.SourceURI, + StoreRef: storeRef, + Tags: []string{"portable", "workstation"}, + Action: action, + DisplayLabel: src.DisplayName + " (" + src.MountPoint + ")", + Selected: true, }) plan.Coverage.ProtectedNow = append(plan.Coverage.ProtectedNow, src.DisplayName+" ("+src.MountPoint+")") } @@ -174,6 +180,9 @@ func ApplyWorkstationSetupPlan(cfg *ProfilesConfig, plan *WorkstationSetupPlan) } for _, draft := range plan.Profiles { + if !draft.Selected { + continue + } if strings.TrimSpace(draft.Name) == "" { return nil, fmt.Errorf("workstation setup plan contains a draft with no profile name") } diff --git a/internal/engine/workstation_setup_test.go b/internal/engine/workstation_setup_test.go index cfb8aa8..20904e0 100644 --- a/internal/engine/workstation_setup_test.go +++ b/internal/engine/workstation_setup_test.go @@ -129,8 +129,8 @@ func TestApplyWorkstationSetupPlan(t *testing.T) { } result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{ Profiles: []WorkstationProfileDraft{ - {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}}, - {Name: "archive", SourceURI: "local:/Volumes/Archive", StoreRef: "primary", Tags: []string{"portable", "workstation"}}, + {Name: "documents", SourceURI: "local:/Users/test/Documents", StoreRef: "primary", Tags: []string{"workstation"}, Selected: true}, + {Name: "archive", SourceURI: "local:/Volumes/Archive", StoreRef: "primary", Tags: []string{"portable", "workstation"}, Selected: true}, }, }) if err != nil { @@ -147,17 +147,36 @@ func TestApplyWorkstationSetupPlan(t *testing.T) { } } +func TestApplyWorkstationSetupPlan_SkipsDeselectedDrafts(t *testing.T) { + cfg := &ProfilesConfig{} + result, err := ApplyWorkstationSetupPlan(cfg, &WorkstationSetupPlan{ + Profiles: []WorkstationProfileDraft{ + {Name: "documents", SourceURI: "local:/Users/test/Documents", Selected: true}, + {Name: "desktop", SourceURI: "local:/Users/test/Desktop", Selected: false}, + }, + }) + if err != nil { + t.Fatalf("ApplyWorkstationSetupPlan: %v", err) + } + if result.ProfilesCreated != 1 { + t.Fatalf("ProfilesCreated = %d, want 1", result.ProfilesCreated) + } + if _, ok := cfg.Profiles["desktop"]; ok { + t.Fatal("desktop profile should not be created") + } +} + func TestApplyWorkstationSetupPlan_Errors(t *testing.T) { if _, err := ApplyWorkstationSetupPlan(nil, nil); err == nil { t.Fatal("expected nil plan error") } if _, err := ApplyWorkstationSetupPlan(nil, &WorkstationSetupPlan{ - Profiles: []WorkstationProfileDraft{{Name: "", SourceURI: "local:/tmp"}}, + Profiles: []WorkstationProfileDraft{{Name: "", SourceURI: "local:/tmp", Selected: true}}, }); err == nil { t.Fatal("expected missing name error") } if _, err := ApplyWorkstationSetupPlan(nil, &WorkstationSetupPlan{ - Profiles: []WorkstationProfileDraft{{Name: "docs", SourceURI: ""}}, + Profiles: []WorkstationProfileDraft{{Name: "docs", SourceURI: "", Selected: true}}, }); err == nil { t.Fatal("expected missing source error") }