diff --git a/cmd/pad/collection_create_schema_test.go b/cmd/pad/collection_create_schema_test.go new file mode 100644 index 00000000..de4fc1da --- /dev/null +++ b/cmd/pad/collection_create_schema_test.go @@ -0,0 +1,260 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TestCollectionSchemaJSONFromFlags_Inline verifies the inline JSON path: +// schemaInput is the literal JSON, gets unmarshaled then re-marshaled +// through the CollectionSchema shape (which normalizes / validates it). +func TestCollectionSchemaJSONFromFlags_Inline(t *testing.T) { + in := `{"fields":[{"key":"status","label":"Status","type":"select","options":["new","done"],"terminal_options":["done"],"default":"new","required":true}]}` + + out, err := collectionSchemaJSONFromFlags(in, "", strings.NewReader("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got models.CollectionSchema + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(got.Fields) != 1 { + t.Fatalf("expected 1 field, got %d", len(got.Fields)) + } + f := got.Fields[0] + if f.Key != "status" || f.Type != "select" { + t.Fatalf("unexpected key/type: %+v", f) + } + if len(f.TerminalOptions) != 1 || f.TerminalOptions[0] != "done" { + t.Fatalf("expected terminal_options=[done], got %v", f.TerminalOptions) + } + if !f.Required || f.Default != "new" { + t.Fatalf("expected required=true default=new, got required=%v default=%v", f.Required, f.Default) + } +} + +// TestCollectionSchemaJSONFromFlags_File verifies the @ file path resolver. +func TestCollectionSchemaJSONFromFlags_File(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "schema.json") + payload := `{"fields":[{"key":"status","type":"select","options":["a","b"],"terminal_options":["b"]}]}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatalf("write tmpfile: %v", err) + } + + out, err := collectionSchemaJSONFromFlags("@"+path, "", strings.NewReader("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got models.CollectionSchema + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(got.Fields) != 1 || got.Fields[0].TerminalOptions[0] != "b" { + t.Fatalf("expected terminal_options=[b], got %+v", got.Fields) + } +} + +// TestCollectionSchemaJSONFromFlags_Stdin verifies the "-" stdin resolver. +func TestCollectionSchemaJSONFromFlags_Stdin(t *testing.T) { + stdin := strings.NewReader(`{"fields":[{"key":"priority","type":"select","options":["lo","hi"]}]}`) + out, err := collectionSchemaJSONFromFlags("-", "", stdin) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got models.CollectionSchema + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(got.Fields) != 1 || got.Fields[0].Key != "priority" { + t.Fatalf("unexpected schema: %+v", got) + } +} + +// TestCollectionSchemaJSONFromFlags_BothFlagsError checks the mutually-exclusive guard. +func TestCollectionSchemaJSONFromFlags_BothFlagsError(t *testing.T) { + _, err := collectionSchemaJSONFromFlags(`{"fields":[]}`, "status:select:open,done", strings.NewReader("")) + if err == nil { + t.Fatal("expected error when both --fields and --schema set, got nil") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually-exclusive error, got: %v", err) + } +} + +// TestCollectionSchemaJSONFromFlags_MalformedJSON ensures the unmarshal error +// is wrapped with the "invalid --schema JSON" prefix so users can see which +// flag caused the problem. +func TestCollectionSchemaJSONFromFlags_MalformedJSON(t *testing.T) { + _, err := collectionSchemaJSONFromFlags(`{this is not json`, "", strings.NewReader("")) + if err == nil { + t.Fatal("expected error for malformed JSON, got nil") + } + if !strings.Contains(err.Error(), "invalid --schema JSON") { + t.Fatalf("expected 'invalid --schema JSON' in error, got: %v", err) + } +} + +// TestCollectionSchemaJSONFromFlags_MissingFile ensures a clear error when +// the @path target doesn't exist. +func TestCollectionSchemaJSONFromFlags_MissingFile(t *testing.T) { + _, err := collectionSchemaJSONFromFlags("@/nonexistent/path/schema.json", "", strings.NewReader("")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + if !strings.Contains(err.Error(), "read --schema file") { + t.Fatalf("expected 'read --schema file' in error, got: %v", err) + } +} + +// TestCollectionSchemaJSONFromFlags_EmptyFallsThroughToFields verifies that an +// empty --schema falls through to the --fields DSL parser (backward compat). +func TestCollectionSchemaJSONFromFlags_EmptyFallsThroughToFields(t *testing.T) { + out, err := collectionSchemaJSONFromFlags("", "status:select:open,done", strings.NewReader("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got models.CollectionSchema + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(got.Fields) != 1 { + t.Fatalf("expected 1 field, got %d", len(got.Fields)) + } + f := got.Fields[0] + if f.Key != "status" || f.Type != "select" { + t.Fatalf("unexpected key/type: %+v", f) + } + // DSL preserves the legacy "first status select gets required+default" heuristic. + if !f.Required || f.Default != "open" { + t.Fatalf("expected legacy DSL heuristic (required=true, default=open), got required=%v default=%v", f.Required, f.Default) + } +} + +// TestCollectionSchemaJSONFromFlags_BothEmptyReturnsEmptySchema verifies the +// no-flags case yields an empty schema (and no error). +func TestCollectionSchemaJSONFromFlags_BothEmptyReturnsEmptySchema(t *testing.T) { + out, err := collectionSchemaJSONFromFlags("", "", strings.NewReader("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != `{"fields":null}` && out != `{"fields":[]}` && out != `{}` { + // We accept any JSON shape that round-trips to a Fields-less schema — + // the wire format here is opaque to callers. + var got models.CollectionSchema + if jsonErr := json.Unmarshal([]byte(out), &got); jsonErr != nil { + t.Fatalf("unparseable empty-schema output %q: %v", out, jsonErr) + } + if len(got.Fields) != 0 { + t.Fatalf("expected empty Fields, got %d", len(got.Fields)) + } + } +} + +// TestCollectionSchemaJSONFromFlags_BackfillsMissingLabels verifies that a +// schema field with no `label` gets one auto-filled from its `key` using +// Title-Case, matching the legacy --fields DSL behavior. Without this, +// agents constructing JSON that omits `label` would create collections +// that render blank field headers in the web UI. +func TestCollectionSchemaJSONFromFlags_BackfillsMissingLabels(t *testing.T) { + in := `{"fields":[ + {"key":"status","type":"select","options":["open","done"]}, + {"key":"due_date","type":"date"}, + {"key":"already_labeled","label":"Custom Label","type":"text"} + ]}` + out, err := collectionSchemaJSONFromFlags(in, "", strings.NewReader("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got models.CollectionSchema + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(got.Fields) != 3 { + t.Fatalf("expected 3 fields, got %d", len(got.Fields)) + } + if got.Fields[0].Label != "Status" { + t.Errorf("expected label 'Status' for key 'status', got %q", got.Fields[0].Label) + } + if got.Fields[1].Label != "Due Date" { + t.Errorf("expected label 'Due Date' for key 'due_date', got %q", got.Fields[1].Label) + } + if got.Fields[2].Label != "Custom Label" { + t.Errorf("explicit label clobbered: got %q want 'Custom Label'", got.Fields[2].Label) + } +} + +// TestCollectionSchemaJSONFromFlags_PreservesAllFieldDefProperties is the +// regression test for BUG-1284: confirms that every FieldDef property — not +// just the DSL-expressible subset — round-trips through --schema unchanged. +func TestCollectionSchemaJSONFromFlags_PreservesAllFieldDefProperties(t *testing.T) { + in := `{ + "fields": [ + { + "key": "status", + "label": "Status", + "type": "select", + "options": ["idea","drafting","review","approved","scheduled","published","archived"], + "terminal_options": ["published","archived"], + "default": "idea", + "required": true + }, + { + "key": "progress", + "label": "Progress", + "type": "number", + "computed": true, + "suffix": "%" + }, + { + "key": "parent_plan", + "label": "Parent Plan", + "type": "relation", + "collection": "plans" + } + ] + }` + out, err := collectionSchemaJSONFromFlags(in, "", strings.NewReader("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var got models.CollectionSchema + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if len(got.Fields) != 3 { + t.Fatalf("expected 3 fields, got %d", len(got.Fields)) + } + + status := got.Fields[0] + if len(status.TerminalOptions) != 2 || status.TerminalOptions[0] != "published" || status.TerminalOptions[1] != "archived" { + t.Errorf("status.terminal_options not preserved: %v", status.TerminalOptions) + } + if status.Default != "idea" { + t.Errorf("status.default not preserved: %v", status.Default) + } + if !status.Required { + t.Errorf("status.required not preserved") + } + + progress := got.Fields[1] + if !progress.Computed { + t.Errorf("progress.computed not preserved") + } + if progress.Suffix != "%" { + t.Errorf("progress.suffix not preserved: %q", progress.Suffix) + } + + parent := got.Fields[2] + if parent.Collection != "plans" { + t.Errorf("relation.collection not preserved: %q", parent.Collection) + } +} diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 85ca4476..da337d94 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -4820,11 +4820,123 @@ func collectionsCmd() *cobra.Command { } } +// collectionSchemaJSONFromFlags resolves the --schema and --fields flags into +// a marshaled CollectionSchema JSON string. +// +// Exactly one of schemaInput or fieldsDSL may be non-empty. When both are +// empty, returns "{}" — an empty schema with no fields. +// +// schemaInput input modes: +// - "" — fall through to fieldsDSL (or empty schema if that is also empty) +// - "-" — read full JSON from stdin +// - "@" — read full JSON from the file at +// - anything else — treat the value itself as an inline JSON literal +func collectionSchemaJSONFromFlags(schemaInput, fieldsDSL string, stdin io.Reader) (string, error) { + if schemaInput != "" && fieldsDSL != "" { + return "", fmt.Errorf("--fields and --schema are mutually exclusive") + } + + if schemaInput != "" { + data, err := readSchemaInputBytes(schemaInput, stdin) + if err != nil { + return "", err + } + var schema models.CollectionSchema + if err := json.Unmarshal(data, &schema); err != nil { + return "", fmt.Errorf("invalid --schema JSON: %w", err) + } + // Backfill missing labels from keys using the same Title-Case-of-key + // heuristic the legacy --fields DSL applies. Without this, schemas + // that omit `label` render blank field headers in the web UI — easy + // for an agent constructing JSON to forget. + for i := range schema.Fields { + if schema.Fields[i].Label == "" && schema.Fields[i].Key != "" { + schema.Fields[i].Label = cases.Title(language.English).String(strings.ReplaceAll(schema.Fields[i].Key, "_", " ")) + } + } + out, err := json.Marshal(schema) + if err != nil { + return "", fmt.Errorf("re-marshal schema: %w", err) + } + return string(out), nil + } + + schema, err := parseFieldsDSL(fieldsDSL) + if err != nil { + return "", err + } + out, err := json.Marshal(schema) + if err != nil { + return "", fmt.Errorf("marshal schema from --fields: %w", err) + } + return string(out), nil +} + +// readSchemaInputBytes resolves the --schema flag value into raw JSON bytes, +// honoring the "-" (stdin), "@path" (file), and inline-literal modes. +func readSchemaInputBytes(input string, stdin io.Reader) ([]byte, error) { + switch { + case input == "-": + data, err := io.ReadAll(stdin) + if err != nil { + return nil, fmt.Errorf("read --schema from stdin: %w", err) + } + return data, nil + case strings.HasPrefix(input, "@"): + path := input[1:] + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read --schema file %q: %w", path, err) + } + return data, nil + default: + return []byte(input), nil + } +} + +// parseFieldsDSL parses the legacy --fields DSL (key:type[:options];...) into +// a CollectionSchema. Empty input returns an empty schema with no error. +func parseFieldsDSL(fieldsDSL string) (models.CollectionSchema, error) { + schema := models.CollectionSchema{} + if fieldsDSL == "" { + return schema, nil + } + for _, f := range strings.Split(fieldsDSL, ";") { + f = strings.TrimSpace(f) + if f == "" { + continue + } + parts := strings.SplitN(f, ":", 3) + if len(parts) < 2 { + return schema, fmt.Errorf("invalid field definition: %q (expected key:type[:options])", f) + } + fd := models.FieldDef{ + Key: parts[0], + Label: cases.Title(language.English).String(strings.ReplaceAll(parts[0], "_", " ")), + Type: parts[1], + } + if len(parts) == 3 && parts[2] != "" { + fd.Options = strings.Split(parts[2], ",") + } + // First select field gets required+default — preserved for + // backward compat with the pre-existing DSL behavior. + if fd.Type == "select" && fd.Key == "status" { + fd.Required = true + if len(fd.Options) > 0 { + fd.Default = fd.Options[0] + } + } + schema.Fields = append(schema.Fields, fd) + } + return schema, nil +} + func collectionsCreateCmd() *cobra.Command { var ( icon string description string fieldsDSL string + schemaInput string layout string defaultView string boardGroup string @@ -4835,12 +4947,30 @@ func collectionsCreateCmd() *cobra.Command { Short: "Create a custom collection", Long: `Create a new collection with custom fields. -Fields DSL format: key:type[:option1,option2,...] -Separate multiple fields with newlines or semicolons. +Two ways to define the schema: + + --fields Compact DSL for the simple case: key:type[:option1,option2,...] + Separate multiple fields with semicolons. Does not support + terminal_options, custom defaults, computed fields, suffixes, + or relation collections. + + --schema Full CollectionSchema JSON for everything else. Accepts: + inline JSON: --schema '{"fields":[...]}' + file path: --schema @./schema.json + stdin: --schema - + + --fields and --schema are mutually exclusive. Examples: pad collection create "Bugs" --fields "status:select:new,triaged,fixing,resolved;severity:select:low,medium,high,critical;component:text" - pad collection create "Decisions" --icon "⚖️" --fields "status:select:proposed,accepted,rejected;impact:select:low,medium,high"`, + pad collection create "Decisions" --icon "⚖️" --fields "status:select:proposed,accepted,rejected;impact:select:low,medium,high" + pad collection create "Marketing" --schema '{"fields":[{"key":"status","label":"Status","type":"select","options":["idea","drafting","review","published","archived"],"terminal_options":["published","archived"],"default":"idea","required":true}]}' + pad collection create "Marketing" --schema @./marketing-schema.json + cat schema.json | pad collection create "Marketing" --schema - + +Tip: if you omit "label" on a --schema field, the CLI auto-fills it from +the key using Title Case (e.g. "due_date" → "Due Date") — matching what +the --fields DSL does. Set "label" explicitly when you want a custom display name.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client, _ := getClient() @@ -4848,40 +4978,11 @@ Examples: name := args[0] - // Parse fields DSL into schema JSON - schema := models.CollectionSchema{} - if fieldsDSL != "" { - fields := strings.Split(fieldsDSL, ";") - for _, f := range fields { - f = strings.TrimSpace(f) - if f == "" { - continue - } - parts := strings.SplitN(f, ":", 3) - if len(parts) < 2 { - return fmt.Errorf("invalid field definition: %q (expected key:type[:options])", f) - } - fd := models.FieldDef{ - Key: parts[0], - Label: cases.Title(language.English).String(strings.ReplaceAll(parts[0], "_", " ")), - Type: parts[1], - } - if len(parts) == 3 && parts[2] != "" { - fd.Options = strings.Split(parts[2], ",") - } - // First select field gets required+default - if fd.Type == "select" && fd.Key == "status" { - fd.Required = true - if len(fd.Options) > 0 { - fd.Default = fd.Options[0] - } - } - schema.Fields = append(schema.Fields, fd) - } + schemaJSON, err := collectionSchemaJSONFromFlags(schemaInput, fieldsDSL, os.Stdin) + if err != nil { + return err } - schemaJSON, _ := json.Marshal(schema) - // Build settings settings := models.CollectionSettings{ Layout: layout, @@ -4900,7 +5001,7 @@ Examples: Name: name, Icon: icon, Description: description, - Schema: string(schemaJSON), + Schema: schemaJSON, Settings: string(settingsJSON), } @@ -4924,7 +5025,8 @@ Examples: cmd.Flags().StringVar(&icon, "icon", "", "collection emoji icon") cmd.Flags().StringVar(&description, "description", "", "collection description") - cmd.Flags().StringVar(&fieldsDSL, "fields", "", "field definitions (key:type[:options]; ...)") + cmd.Flags().StringVar(&fieldsDSL, "fields", "", "field definitions DSL (key:type[:options]; ...); use --schema for terminal_options, computed, defaults, etc.") + cmd.Flags().StringVar(&schemaInput, "schema", "", "full CollectionSchema JSON: inline, @path, or - for stdin; mutually exclusive with --fields") cmd.Flags().StringVar(&layout, "layout", "fields-primary", "item detail layout: fields-primary, content-primary, balanced") cmd.Flags().StringVar(&defaultView, "default-view", "list", "default view type: list, board, table") cmd.Flags().StringVar(&boardGroup, "board-group-by", "status", "field to group by in board view")