Files
pad/internal/collections/dsl.go
T
xarmian f5579300fb feat(cli,mcp): expose 'collection update' via CLI and MCP catalog (TASK-1510) (#572)
* feat(cli,mcp): expose 'collection update' via CLI and MCP catalog (TASK-1510)

The HTTP handler at handlers_collections.go::handleUpdateCollection
already supported PATCHing a collection's name, icon, description,
prefix, schema, settings, and sort_order (plus field-value migrations).
The CLI and MCP surfaces never exposed it, so agents couldn't rename
collections, swap icons, or reshape schemas — a hard blocker for the
adaptive /pad onboard playbook (TASK-1499) which needs to rewrite
seeded collections to match each project's actual vocabulary.

This wires both agent-facing surfaces to the existing handler:

- cmd/pad: new 'pad collection update <slug>' Cobra subcommand with
  --name / --icon / --description / --prefix / --schema / --fields /
  --sort-order flags. Only flags explicitly set are sent (uses
  cmd.Flags().Changed); --schema and --fields reuse the existing
  collectionSchemaJSONFromFlags helper so DSL parity stays.

- internal/mcp/catalog_collection: add 'update' action plus the
  slug, prefix, and sort_order params on padCollectionTool.

- internal/mcp/dispatch_http_routes: new mapCollectionUpdate handles
  the schema-object-vs-string coercion. The catalog declares schema
  as a JSON object for MCP ergonomics, but
  models.CollectionUpdate.Schema is *string — and its UnmarshalJSON
  only flexes settings, not schema. The mapper re-marshals object
  input to its JSON-string form before sending, symmetric to what
  the CLI does via collectionSchemaJSONFromFlags.

Tests cover canonical body, schema-object-to-string coercion
(round-trip through CollectionUpdate.UnmarshalJSON), schema-string
pass-through, empty-field omission, and required-arg validation.
catalog_readonly_test bijection + liveCmdhelpDoc fake updated.

Parent: PLAN-1496.

* fix(mcp): collection update — clear-on-empty + fields DSL parity per Codex review (round 1)

Addresses two P2 findings on PR #572:

1. The catalog advertises `icon=""` / `description=""` / `prefix=""`
   as clear-the-field, and the CLI flag help says the same, but the
   HTTP mapper filtered empty strings via `v != ""` — leaving MCP HTTP
   callers unable to clear fields the CLI can. Switched to key-presence
   semantics for the four string fields so explicit empty strings
   round-trip to the store (which honors *string("") as "clear").

2. The catalog advertises `fields OR schema` as mutually exclusive
   (mirroring `pad collection create`), but the mapper only consumed
   `schema`. An MCP HTTP request with `fields=...` produced a `{}` PATCH
   body silently. Extracted the DSL parser to a shared package
   (internal/collections/dsl.go::ParseFieldsDSL + FieldsDSLToSchemaJSON)
   so the CLI and the mapper share one parser; mapper now resolves
   fields-or-schema with the same mutual-exclusion guard the CLI has.

Tests added in dispatch_http_routes_extras_test.go:
- TestMapCollectionUpdate_EmptyStringClearsField
- TestMapCollectionUpdate_AcceptsFieldsDSL (round-trips through
  models.CollectionSchema to confirm the parsed shape)
- TestMapCollectionUpdate_RejectsFieldsAndSchemaTogether

cmd/pad/main.go's parseFieldsDSL becomes a one-line alias for
collections.ParseFieldsDSL so the CLI's behavior stays identical.

Parent: PLAN-1496, fixing PR #572 / TASK-1510.

* fix(mcp): collection update — use encodeSchemaForBody + normalize empty schema (round 2)

Addresses two more findings from Codex round 2 on PR #572:

1. P2: mapCollectionUpdate bypassed encodeSchemaForBody, so structured
   schemas didn't get label backfill and string schemas weren't
   validated before PATCH — diverged from collection create + CLI.
   Now reuses encodeSchemaForBody (the same encoder collection create
   uses at dispatch_http_routes.go:418), getting label-backfill via
   the Title-Case-of-key heuristic and shape validation for free.

2. P3: schema=null or schema="" plus a real fields=... update tripped
   the mutual-exclusion check. Now normalizes empty inputs as absent
   BEFORE checking exclusivity, matching the relaxed handling
   collection create has for optional empty params.

Tests:
- Renamed TestMapCollectionUpdate_PassesSchemaStringVerbatim to
  TestMapCollectionUpdate_AcceptsSchemaString — the new property is
  round-trip parity + label backfill, not verbatim pass-through.
- New TestMapCollectionUpdate_EmptySchemaDoesNotBlockFields covers
  both nil and empty-string schema combined with a real fields value.

Parent: PLAN-1496, addressing Codex round 2 on PR #572 / TASK-1510.
2026-05-17 01:45:40 -04:00

80 lines
2.6 KiB
Go

// Package collections holds collection-shape utilities shared by the
// CLI, the MCP HTTP dispatcher, and (eventually) any other surface
// that needs to translate user-friendly DSL forms into the canonical
// JSON shapes the API consumes.
//
// `dsl.go` is the home for the legacy "compact field DSL" parser that
// originally lived in cmd/pad/main.go. Moved up to this shared package
// (PR #572 follow-up to Codex finding on TASK-1510) so the MCP HTTP
// route mapper can accept `fields=...` for `collection update` without
// reimplementing the parser. The CLI calls the same helper via
// CollectionSchemaJSONFromDSL; both surfaces now have parity.
package collections
import (
"encoding/json"
"fmt"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// ParseFieldsDSL parses the compact field DSL (key:type[:options];...)
// into a CollectionSchema. Empty input returns an empty schema with no
// error.
//
// First select field named `status` automatically gets `required=true`
// and `default=<first option>` — backward-compatible behavior preserved
// from the original cmd/pad implementation.
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], ",")
}
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
}
// FieldsDSLToSchemaJSON parses the DSL and returns the marshaled
// CollectionSchema JSON string. Empty input returns "{}" — an empty
// schema. Convenience wrapper for callers that want to feed the
// result straight into models.CollectionUpdate.Schema (which is a
// *string).
func FieldsDSLToSchemaJSON(fieldsDSL string) (string, error) {
schema, err := ParseFieldsDSL(fieldsDSL)
if err != nil {
return "", err
}
out, err := json.Marshal(schema)
if err != nil {
return "", fmt.Errorf("marshal schema from DSL: %w", err)
}
return string(out), nil
}