diff --git a/README.md b/README.md index a2e9f48..8eb1259 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,84 @@ markfluence read 1234567890 --format storage > page.storage.xml markfluence read "https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title" ``` +### `--json` output + +The persistent `--json` flag makes any command emit a single machine-readable +JSON document to stdout instead of the human output, for scripting and CI. It +pipes cleanly to `jq`: + +```sh +markfluence info 1234567890 --json | jq '.results[0].page_width' +markfluence update docs/*.md --json | jq '.summary' +``` + +Output is a stable, versioned **envelope**. `results` always holds one object per +target (a single element for `info`/`read`); `summary` carries batch counts: + +```json +{ + "schema_version": 1, + "markfluence_version": "1.4.0", + "command": "update", + "results": [ + { + "ok": true, + "status": "published", + "file": "docs/foo.md", + "page_id": "123", + "title": "Foo", + "space": "ENG", + "url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo", + "version": { "previous": 3, "new": 4 }, + "page_width": { "value": "max", "default": false }, + "attachments": [ { "action": "updated", "filename": "diagram.png" } ], + "warnings": [], + "broken": [], + "error": null, + "code": null + } + ], + "summary": { "total": 1, "succeeded": 1, "failed": 0, "skipped": 0 } +} +``` + +The full contract is published as a JSON Schema (draft 2020-12) at +[`schema/json-output/v1.json`](schema/json-output/v1.json) — the `results` item +and `summary` shapes are selected by `command`, and the stderr error object is +`#/$defs/errorObject`. A test validates markfluence's actual output against it, so +the schema cannot drift from the implementation. + +Notes on the schema: + +- **Per-command stable.** Each command always emits the same keys in the same + shapes (empty values are `null` or `[]`); the key *set* differs per command. + `schema_version` is bumped on any breaking change. +- **Status verbs** are per-command: `published`/`skipped` (`update`), + `created`/`not_created` (`create`), `changed`/`consistent` (`fix`), plus + `failed`. `info`/`read` results carry data only (no status verb). +- **Compound values are objects**, never display strings — `version`, + `page_width`, and the `created`/`updated` author stamps on `info`. +- **`create`'s two-phase abort** (a validation failure means nothing is created) + lists every input file — failed ones with an `error`, the rest as + `not_created` — and sets `summary.aborted: true`. +- **Warnings and broken image/link notices** are data (`warnings`/`broken` + arrays on each result), not stderr log lines. + +Errors and exit codes: + +- **Per-file operational failures** appear in `results` as + `{ "ok": false, "error": "…", "code": "…" }`; the command exits `1` if any + file failed. +- **Fatal/pre-flight failures** (bad flags, credential resolution) print a typed + error object to **stderr** and exit `2`: + + ```json + { "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG" } + ``` + +- Error `code` values: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`, + `IO`, `NETWORK`, `API`. + ## Markdown page structure Each Markdown file is one Confluence page: an optional YAML **frontmatter** block diff --git a/_plans/015_json-output.md b/_plans/015_json-output.md new file mode 100644 index 0000000..5bccb47 --- /dev/null +++ b/_plans/015_json-output.md @@ -0,0 +1,226 @@ +# Plan: `--json` machine-readable output + +Add a global `--json` mode across all five subcommands (`info`, `read`, `update`, +`create`, `fix`) so markfluence output can be consumed by scripts and CI. Closes +issue #12 ("Add --json output for commands"). + +`--json` suppresses the human `internal/ui` output and writes a single structured +JSON document to stdout. Warnings and broken-image/link notices — currently +`ui.Warn` lines on stderr — become **data** in the payload (issue #12 lists them +as schema fields). Fatal/pre-flight errors stay on stderr, but as a typed JSON +error object when `--json` is set. + +Reference: pchuri/confluence-cli's `--json` (global flag, stdout-stays-valid-JSON, +typed error codes on stderr). We diverge deliberately in two places: we wrap +output in an **envelope** (it has no envelope), and we fold warnings/broken into +the **payload** (it logs them to stderr) — because markfluence's warnings are +per-page conversion results, not incidental log chatter. + +## Decisions locked (from the interview) + +- **Scope:** all five subcommands, including `read`. +- **Flag:** one persistent `--json` bool on `rootCmd`, inherited by every + subcommand (like `--debug`/`--no-color`). Bare `markfluence --json` (help) is + unaffected — `--json` only shapes subcommand output. +- **Envelope:** uniform for every command, including single-target `info`/`read` + (their `results` is a 1-element array). +- **Field presence:** stable schema means **per-command stable** — each command + always emits the same keys with the same shapes (empty → `null`/`[]`); the key + *set* differs between commands. +- **Compound values are nested objects**, never human display strings. +- **Status verbs are per-command** (`published`/`created`/`changed`, etc.). +- **create phase-1 abort:** every input file appears in `results`; summary carries + `aborted:true`. +- **Refactor depth:** full — `processFile`/`createOne` build a typed result + struct; a human renderer and the JSON collector both consume it (single source + of truth). +- **Errors:** per-file operational failures live in `results`; fatal/pre-flight + errors go to stderr as a typed error object. Typed error codes, markfluence- + tailored set. +- **Formatting:** pretty-printed, 2-space indent, newline-terminated. +- **Exit codes:** `0` ok, `1` operational failure, `2` config/usage/pre-flight. + +## Architecture + +### `internal/jsonout` (new package) + +Holds the shared machinery so no command re-implements it: + +- `Envelope` — `{schema_version, markfluence_version, command, results, summary}`. + `results` is `[]any`; `summary` is `any` (command-specific). +- `ErrorObject` — `{schema_version, command, error, code}` for the fatal path. +- `Code` constants: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`, `IO`, + `NETWORK`, `API`. +- `Emit(w io.Writer, env Envelope) error` — marshal indented (2-space), trailing + newline, to stdout. +- `EmitError(w io.Writer, command, msg string, code Code) error` — the stderr + error object. +- A helper to derive a `Code` from a `client.HTTPError` (401/403→`AUTH`, + 404→`NOT_FOUND`, other→`API`) with `NETWORK` for transport errors. + +`schema_version` is the integer `1` (bump on breaking change). +`markfluence_version` is `buildinfo.Stamp` (aids bug reports). + +### `internal/ui` + +- `SetJSON(bool)` — when set, the stdout helpers (`Header`/`Success`/`Info`/`Dim`) + become no-ops, a belt-and-suspenders guard so a stray call can't corrupt the + JSON on stdout. `ui.Warn`/`ui.Error` also stop writing in JSON mode (their + content is carried in the payload / error object instead). `ui.Debug` is + unaffected — still stderr, still `--debug`-gated. +- Wired from `rootCmd.PersistentPreRunE` next to `SetDebug`. + +### Command refactor (the bulk of the diff) + +Each command's per-file worker builds a typed result struct instead of printing +inline and returning `bool`: + +- `update.processFile` / `create.createOne` / `fix.processFile` → return a result + struct (and an in-band error/status). +- A **human renderer** re-derives today's `ui.*` lines from that struct, so + human-mode output is byte-identical to now. +- A **JSON collector** appends structs to `Envelope.results` and prints once at + the end. + +Human-mode output must not regress; the renderer is exercised by the existing +command behavior. + +## Envelope + +```json +{ + "schema_version": 1, + "markfluence_version": "1.4.0", + "command": "update", + "results": [ /* per-command result objects */ ], + "summary": { "total": 2, "succeeded": 1, "failed": 1 } +} +``` + +Summary core is `{total, succeeded, failed}`; commands add extras (below). + +## Per-command result shapes + +### update — `status`: `published` | `skipped` | `failed` + +```json +{ "ok": true, "status": "published", "file": "docs/foo.md", + "page_id": "123", "title": "Foo", "space": "ENG", + "url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo", + "version": { "previous": 3, "new": 4 }, + "page_width": { "value": "max", "default": false }, + "attachments": [ { "action": "updated", "filename": "diagram.png" } ], + "warnings": [], "broken": [], "error": null, "code": null } +``` + +- `skipped` (mtime unchanged): `ok:true`, `version.new == version.previous`. +- `failed`: `ok:false`, `error` set, `code` set. +- Summary extra: `skipped`. + +### create — `status`: `created` | `not_created` | `failed` + +```json +{ "ok": true, "status": "created", "file": "docs/foo.md", + "page_id": "456", "title": "Foo", "space": "ENG", "parent": "123", + "url": "...", "page_width": { "value": "max", "default": false }, + "persisted": true, + "attachments": [ ... ], "warnings": [], "broken": [], + "error": null, "code": null } +``` + +- Phase-1 abort: validation-failed files → `{ok:false,status:"failed",code:"VALIDATION",error}`; + the rest → `{ok:false,status:"not_created",error:null}`. +- Summary: `{total, succeeded, failed, aborted}`. + +### fix — `status`: `changed` | `consistent` | `failed` + +```json +{ "ok": true, "status": "changed", "file": "docs/foo.md", "page_id": "123", + "dry_run": false, + "changes": [ { "field": "space", "old": "OLD", "new": "ENG" } ], + "warnings": [], "error": null, "code": null } +``` + +- `consistent`: `changes` empty. +- `--dry-run`: `dry_run:true`, status still `changed` when changes exist, no file + written. +- Summary extras: `changed`, `consistent`. + +### info — data only (no operational verb) + +```json +{ "ok": true, "file": null, "page_id": "123", "title": "Foo", + "page_status": "current", "space": "ENG", "parent": "456", + "version": { "number": 7 }, + "page_width": { "value": "max", "default": true }, + "created": { "at": "…", "by": { "account_id": "…", "name": "Will" } }, + "updated": { "at": "…", "by": { "account_id": "…", "name": "Will" } }, + "message": "Updated via markfluence", "url": "…", + "properties": null } +``` + +- Confluence's `status` field is renamed `page_status` to avoid colliding with the + result-status concept used by the action commands. +- `properties` stays gated on `--properties`: `null` when the flag is absent, an + array of `{key, value}` (sorted by key) when present. No extra API call unless + asked. +- **Page not found** is a `results[0]` entry `{ok:false,code:"NOT_FOUND"}` with + exit `1` — *not* a fatal stderr error (that path is reserved for config/usage). + Keeps "operational failures live in the payload" consistent. + +### read — structured fields + body string + +```json +{ "ok": true, "page_id": "123", "title": "X", "space": "ENG", + "parent": null, "page_width": { "value": "max", "default": true }, + "format": "markdown", "body": "# X\n\nhello" } +``` + +- `page_width` is the same nested object as `info` (null when the width read + fails). `format` echoes `--format` (`markdown` | `storage`). `parent` is `null` + for top-level, else the parent page id. + +## Errors & exit codes + +- **Fatal / pre-flight** (bad flags, `client.Resolve`/auth) → JSON error object on + **stderr**, no stdout payload, **exit 2**: + + ```json + { "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG" } + ``` + +- **Per-file operational** failures → `{ok:false, error, code}` in `results`; + **exit 1** if any file failed. +- Codes derived from `client.HTTPError` status + failure site (see + `internal/jsonout`). + +## Testing + +- `internal/jsonout`: unit-test `Emit`/`EmitError` output (golden strings) and the + `HTTPError`→`Code` mapping. +- Each `cmd/*`: golden JSON tests built from hand-constructed `client.Page` / + result-struct values (like `info_test.go` builds `client.Property` values) — no + network mock needed. Cover: a success, a per-file failure, warnings/broken + populated; for create, the phase-1 abort envelope; for fix, `--dry-run`. +- `cmd`: extend `root_test.go` to assert the `--json` persistent flag is + registered. +- `make test && make lint && make vet` before done. + +## Docs + +- Document `--json` in the README (envelope shape, the per-command result keys, the + error object, exit-code table) and note the schema is versioned via + `schema_version`. + +## Published schema (drift-guarded) + +The full contract is a JSON Schema (draft 2020-12) at `schema/json-output/v1.json`: +the envelope root, per-command `results`/`summary` selected via `if/then` on +`command`, and the stderr error object at `#/$defs/errorObject`. Every object uses +`additionalProperties: false`, so a new struct field fails validation until the +schema is updated. + +`internal/schematest` (a test-only helper using `santhosh-tekuri/jsonschema/v6`) +loads and compiles the schema once; each command's `TestSchemaConformance` +validates the command's *actual* marshaled output against it, so the schema cannot +drift from the Go structs. diff --git a/cmd/create/create.go b/cmd/create/create.go index ef60b55..306f626 100644 --- a/cmd/create/create.go +++ b/cmd/create/create.go @@ -14,6 +14,7 @@ import ( "github.com/mozilla/markfluence/internal/client" "github.com/mozilla/markfluence/internal/convert" "github.com/mozilla/markfluence/internal/frontmatter" + "github.com/mozilla/markfluence/internal/jsonout" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -75,10 +76,12 @@ type record struct { width pagewidth.Width } +// failure is a phase-1 validation error against a file (or "(hierarchy)"). +type failure struct{ filename, message string } + func run(cmd *cobra.Command, args []string) error { if overrideNeedsSingleFile(titleOpt, len(args)) { - ui.Error("--title applies to a single page; pass exactly one FILE") - return ui.ErrSilent + return fatalFail("--title applies to a single page; pass exactly one FILE", jsonout.CodeConfig) } doPersist := wantPersist(persistOpt, noPersistOpt) @@ -87,8 +90,7 @@ func run(cmd *cobra.Command, args []string) error { envFile, _ := cmd.Flags().GetString("env-file") c, err := client.Resolve(url, username, envFile) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return fatalFail(err.Error(), jsonout.CodeConfig) } inSetAbs := map[string]bool{} @@ -100,7 +102,6 @@ func run(cmd *cobra.Command, args []string) error { spaceCache := map[string]string{} // Phase 1: validate every file, create nothing. - type failure struct{ filename, message string } var records []record var errs []failure for _, filename := range args { @@ -132,35 +133,39 @@ func run(cmd *cobra.Command, args []string) error { } if len(errs) > 0 { - for _, e := range errs { - ui.Error(fmt.Sprintf("[%s] %s", e.filename, e.message)) - } - ui.Error(fmt.Sprintf("Aborting: %d file(s) failed validation; nothing was created.", len(errs))) - return ui.ErrSilent + return abort(args, errs) } // Phase 2: create in topological order. created := map[string]string{} failures := 0 + results := make([]*createResult, 0, len(ordered)) for _, r := range ordered { - prefix := "[" + r.filename + "]" - parentID := r.parent.id - if r.parent.kind == "inset" { - parentID = created[r.parent.abs] - if parentID == "" { - ui.Error(prefix + " parent page was not created; skipping") - failures++ - continue - } - } - newID, url, err := createOne(r, parentID, c, doPersist) - if err != nil { - ui.Error(prefix + " " + err.Error()) + res := createInOrder(r, created, c, doPersist) + if res.ok { + created[r.absPath] = res.pageID + } else { failures++ - continue } - created[r.absPath] = newID - ui.Success(fmt.Sprintf("%s Created page %s: %s", prefix, newID, url)) + results = append(results, res) + if !ui.IsJSON() { + res.renderHuman() + } + } + + if ui.IsJSON() { + items := make([]any, len(results)) + for i, r := range results { + items[i] = r.jsonResult() + } + env := jsonout.NewEnvelope("create", items, summarize(results)) + if err := jsonout.Emit(os.Stdout, env); err != nil { + return err + } + if failures > 0 { + return ui.SilentExit(1) + } + return nil } if failures > 0 { @@ -170,6 +175,23 @@ func run(cmd *cobra.Command, args []string) error { return nil } +// createInOrder resolves the effective parent id for a record and creates it, +// returning a result. A missing in-set parent (its creation failed earlier) is a +// failed result rather than a create attempt. +func createInOrder( + r record, created map[string]string, c *client.ConfluenceClient, doPersist bool, +) *createResult { + parentID := r.parent.id + if r.parent.kind == "inset" { + parentID = created[r.parent.abs] + if parentID == "" { + res := newResult(r) + return res.fail(errors.New("parent page was not created; skipping"), jsonout.CodeValidation) + } + } + return createOne(r, parentID, c, doPersist) +} + func resolveFile( filename string, c *client.ConfluenceClient, inSetAbs map[string]bool, spaceCache map[string]string, ) (record, error) { @@ -345,36 +367,43 @@ func parentField(p parentInfo, parentID string) (value, comment string) { return parentID, p.display } -func createOne(r record, parentID string, c *client.ConfluenceClient, persist bool) (newID, url string, err error) { - prefix := "[" + r.filename + "]" +// createOne creates one page and returns a result. It performs no output; the +// caller renders the result. +func createOne(r record, parentID string, c *client.ConfluenceClient, persist bool) *createResult { + res := newResult(r) + res.parent = nullableStr(parentID) + pageContent, err := convert.MdToConfluence(r.mdfile, c.BaseURL(), r.spaceKey, buildinfo.Stamp()) if err != nil { - return "", "", err - } - for _, msg := range append(append([]string{}, pageContent.Broken...), pageContent.Warnings...) { - ui.Warn(prefix + " " + msg) + return res.fail(err, jsonout.CodeConvert) } + res.broken = append(res.broken, pageContent.Broken...) + res.warnings = append(res.warnings, pageContent.Warnings...) result, err := c.CreatePage(r.spaceID, r.title, pageContent.HTML, parentID) if err != nil { - return "", "", err + return res.fail(err, jsonout.CodeFor(err)) } - newID = result.ID + newID := result.ID + res.pageID = newID + res.url = pageURL(c, result, newID) actions, err := c.SyncAttachments(newID, toLocalAttachments(pageContent.Attachments)) if err != nil { - return "", "", err + return res.fail(err, jsonout.CodeFor(err)) } for _, a := range actions { - ui.Info(fmt.Sprintf("%s attachment %s: %s", prefix, a.Action, a.Filename)) + res.attachments = append(res.attachments, jsonout.Attachment{Action: a.Action, Filename: a.Filename}) } + res.width = &jsonout.PageWidth{Value: string(r.width), Default: false} if acts, err := pagewidth.Apply(c, newID, r.width); err != nil { - ui.Warn(prefix + " could not set page width: " + err.Error()) + res.width = nil + res.warnings = append(res.warnings, "could not set page width: "+err.Error()) } else { for _, a := range acts { if a.Action == "set" { - ui.Info(prefix + " page width: " + string(r.width)) + res.widthSet = true break } } @@ -389,10 +418,14 @@ func createOne(r record, parentID string, c *client.ConfluenceClient, persist bo content = frontmatter.UpdateField(content, "page_id", newID, "") content = frontmatter.UpdateField(content, "page_width", string(r.width), "") if err := os.WriteFile(r.filename, []byte(content), 0o644); err != nil { - return "", "", err + return res.fail(err, jsonout.CodeIO) } + res.persisted = true } - return newID, pageURL(c, result, newID), nil + + res.ok = true + res.status = statusCreated + return res } // wantPersist resolves the --persist/--no-persist pair; --no-persist wins. diff --git a/cmd/create/json.go b/cmd/create/json.go new file mode 100644 index 0000000..0238ead --- /dev/null +++ b/cmd/create/json.go @@ -0,0 +1,236 @@ +package create + +import ( + "fmt" + "os" + + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/ui" +) + +// Per-file status verbs for create. not_created marks a file that passed nothing +// because the batch aborted (or its parent was never created). +const ( + statusCreated = "created" + statusNotCreated = "not_created" + statusFailed = "failed" +) + +// createResult captures the outcome of creating one page. +type createResult struct { + file string + ok bool + status string + pageID string + title string + space string + parent *string + url string + width *jsonout.PageWidth + widthSet bool + persisted bool + attachments []jsonout.Attachment + broken []string + warnings []string + errMsg string + code jsonout.Code +} + +// newResult seeds a result with the fields known before creation is attempted. +func newResult(r record) *createResult { + return &createResult{file: r.filename, title: r.title, space: r.spaceKey} +} + +func (r *createResult) fail(err error, code jsonout.Code) *createResult { + r.ok = false + r.status = statusFailed + r.errMsg = err.Error() + r.code = code + return r +} + +// renderHuman reproduces the original phase-2 inline output for one file. +func (r *createResult) renderHuman() { + prefix := "[" + r.file + "]" + if !r.ok { + ui.Error(prefix + " " + r.errMsg) + return + } + for _, b := range r.broken { + ui.Warn(prefix + " " + b) + } + for _, w := range r.warnings { + ui.Warn(prefix + " " + w) + } + for _, a := range r.attachments { + ui.Info(fmt.Sprintf("%s attachment %s: %s", prefix, a.Action, a.Filename)) + } + if r.widthSet && r.width != nil { + ui.Info(prefix + " page width: " + r.width.Value) + } + ui.Success(fmt.Sprintf("%s Created page %s: %s", prefix, r.pageID, r.url)) +} + +// jsonCreateResult is create's --json result shape. +type jsonCreateResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + File string `json:"file"` + PageID *string `json:"page_id"` + Title *string `json:"title"` + Space *string `json:"space"` + Parent *string `json:"parent"` + URL *string `json:"url"` + PageWidth *jsonout.PageWidth `json:"page_width"` + Persisted bool `json:"persisted"` + Attachments []jsonout.Attachment `json:"attachments"` + Warnings []string `json:"warnings"` + Broken []string `json:"broken"` + Error *string `json:"error"` + Code *jsonout.Code `json:"code"` +} + +func (r *createResult) jsonResult() jsonCreateResult { + res := jsonCreateResult{ + OK: r.ok, + Status: r.status, + File: r.file, + PageID: nullableStr(r.pageID), + Title: nullableStr(r.title), + Space: nullableStr(r.space), + Parent: r.parent, + URL: nullableStr(r.url), + PageWidth: r.width, + Persisted: r.persisted, + Attachments: nonNilAttachments(r.attachments), + Warnings: nonNilStrings(r.warnings), + Broken: nonNilStrings(r.broken), + } + if !r.ok { + res.Error = &r.errMsg + c := r.code + res.Code = &c + } + return res +} + +// createSummary is create's batch summary; aborted is true when phase-1 +// validation failed and nothing was created. +type createSummary struct { + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Aborted bool `json:"aborted"` +} + +func summarize(results []*createResult) createSummary { + s := createSummary{Total: len(results)} + for _, r := range results { + if r.ok { + s.Succeeded++ + } else { + s.Failed++ + } + } + return s +} + +// abort reports a phase-1 validation abort. In human mode it prints each error +// and the abort line; in JSON mode it emits an envelope with every input file +// present (validation-failed ones "failed", the rest "not_created") and an +// aborted summary. Either way it exits 1. +func abort(args []string, errs []failure) error { + if !ui.IsJSON() { + for _, e := range errs { + ui.Error(fmt.Sprintf("[%s] %s", e.filename, e.message)) + } + ui.Error(fmt.Sprintf("Aborting: %d file(s) failed validation; nothing was created.", len(errs))) + return ui.ErrSilent + } + + argSet := map[string]bool{} + for _, a := range args { + argSet[a] = true + } + errMap := map[string]string{} + var extra []failure // failures not tied to an input file, e.g. "(hierarchy)" + for _, e := range errs { + if argSet[e.filename] { + errMap[e.filename] = e.message + } else { + extra = append(extra, e) + } + } + + var items []any + failed := 0 + for _, a := range args { + if msg, bad := errMap[a]; bad { + items = append(items, abortedResult(a, statusFailed, msg, jsonout.CodeValidation)) + failed++ + } else { + items = append(items, abortedResult(a, statusNotCreated, "", "")) + } + } + for _, e := range extra { + items = append(items, abortedResult(e.filename, statusFailed, e.message, jsonout.CodeValidation)) + failed++ + } + + env := jsonout.NewEnvelope("create", items, + createSummary{Total: len(args), Succeeded: 0, Failed: failed, Aborted: true}) + if err := jsonout.Emit(os.Stdout, env); err != nil { + return err + } + return ui.SilentExit(1) +} + +// abortedResult builds a minimal result for a file when the batch aborted before +// creation. Fields that require a live/created page stay null; arrays are []. +func abortedResult(file, status, errMsg string, code jsonout.Code) jsonCreateResult { + res := jsonCreateResult{ + OK: false, + Status: status, + File: file, + Attachments: []jsonout.Attachment{}, + Warnings: []string{}, + Broken: []string{}, + } + if errMsg != "" { + res.Error = &errMsg + c := code + res.Code = &c + } + return res +} + +func nullableStr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func nonNilStrings(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +func nonNilAttachments(a []jsonout.Attachment) []jsonout.Attachment { + if a == nil { + return []jsonout.Attachment{} + } + return a +} + +// fatalFail reports a config/usage/pre-flight failure, exiting 2. +func fatalFail(msg string, code jsonout.Code) error { + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, "create", msg, code) + } else { + ui.Error(msg) + } + return ui.SilentExit(2) +} diff --git a/cmd/create/json_test.go b/cmd/create/json_test.go new file mode 100644 index 0000000..a7da2d3 --- /dev/null +++ b/cmd/create/json_test.go @@ -0,0 +1,128 @@ +package create + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + // A normal (non-aborted) create batch. + parent := "123" + results := []*createResult{ + { + file: "child.md", ok: true, status: statusCreated, + pageID: "456", title: "Child", space: "ENG", parent: &parent, url: "https://x/456", + width: &jsonout.PageWidth{Value: "max", Default: false}, + persisted: true, + }, + (&createResult{file: "bad.md"}).fail(errString("boom"), jsonout.CodeConvert), + } + items := make([]any, len(results)) + for i, r := range results { + items[i] = r.jsonResult() + } + env := jsonout.NewEnvelope("create", items, summarize(results)) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + // The phase-1 abort envelope. + abortItems := []any{ + abortedResult("bad.md", statusFailed, "no title given", jsonout.CodeValidation), + abortedResult("ok.md", statusNotCreated, "", ""), + } + abortEnv := jsonout.NewEnvelope("create", abortItems, + createSummary{Total: 2, Succeeded: 0, Failed: 1, Aborted: true}) + buf.Reset() + if err := jsonout.Emit(&buf, abortEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func TestJSONResultCreated(t *testing.T) { + parent := "123" + r := &createResult{ + file: "child.md", ok: true, status: statusCreated, + pageID: "456", title: "Child", space: "ENG", parent: &parent, + url: "https://wiki.example.net/wiki/spaces/ENG/pages/456/Child", + width: &jsonout.PageWidth{Value: "max", Default: false}, + persisted: true, + } + got, err := json.MarshalIndent(r.jsonResult(), "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{ + "ok": true, + "status": "created", + "file": "child.md", + "page_id": "456", + "title": "Child", + "space": "ENG", + "parent": "123", + "url": "https://wiki.example.net/wiki/spaces/ENG/pages/456/Child", + "page_width": { + "value": "max", + "default": false + }, + "persisted": true, + "attachments": [], + "warnings": [], + "broken": [], + "error": null, + "code": null +}` + if string(got) != want { + t.Errorf("created result mismatch:\n got:\n%s\n want:\n%s", got, want) + } +} + +func TestAbortedResultShapes(t *testing.T) { + // A validation-failed file. + failed := abortedResult("bad.md", statusFailed, "no title given", jsonout.CodeValidation) + if failed.OK || failed.Status != "failed" || failed.Error == nil || + failed.Code == nil || *failed.Code != jsonout.CodeValidation { + t.Errorf("failed abort result unexpected: %+v", failed) + } + // A file that simply wasn't created (batch aborted). + nc := abortedResult("ok.md", statusNotCreated, "", "") + if nc.OK || nc.Status != "not_created" || nc.Error != nil || nc.Code != nil { + t.Errorf("not_created abort result unexpected: %+v", nc) + } + // Arrays must be [] not null. + if nc.Attachments == nil || nc.Warnings == nil || nc.Broken == nil { + t.Errorf("abort arrays must be [], got %+v", nc) + } +} + +func TestSummarize(t *testing.T) { + s := summarize([]*createResult{ + {ok: true, status: statusCreated}, + {ok: false, status: statusFailed}, + }) + if s.Total != 2 || s.Succeeded != 1 || s.Failed != 1 || s.Aborted { + t.Errorf("summary = %+v", s) + } +} + +func TestCreateSummaryAbortedJSON(t *testing.T) { + b, err := json.Marshal(createSummary{Total: 3, Succeeded: 0, Failed: 1, Aborted: true}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"total":3,"succeeded":0,"failed":1,"aborted":true}` + if string(b) != want { + t.Errorf("summary JSON = %s, want %s", b, want) + } +} diff --git a/cmd/fix/fix.go b/cmd/fix/fix.go index 1aaf7e8..3073a26 100644 --- a/cmd/fix/fix.go +++ b/cmd/fix/fix.go @@ -11,6 +11,7 @@ import ( "github.com/mozilla/markfluence/internal/client" "github.com/mozilla/markfluence/internal/frontmatter" + "github.com/mozilla/markfluence/internal/jsonout" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -41,16 +42,42 @@ func run(cmd *cobra.Command, args []string) error { envFile, _ := cmd.Flags().GetString("env-file") c, err := client.Resolve(url, username, envFile) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, "fix", err.Error(), jsonout.CodeConfig) + } else { + ui.Error(err.Error()) + } + return ui.SilentExit(2) } failures := 0 + results := make([]*fixResult, 0, len(args)) for _, filename := range args { - if !processFile(filename, c) { + r := processFile(filename, c) + results = append(results, r) + if !ui.IsJSON() { + r.renderHuman() + } + if !r.ok { failures++ } } + + if ui.IsJSON() { + items := make([]any, len(results)) + for i, r := range results { + items[i] = r.jsonResult() + } + env := jsonout.NewEnvelope("fix", items, summarize(results)) + if err := jsonout.Emit(os.Stdout, env); err != nil { + return err + } + if failures > 0 { + return ui.SilentExit(1) + } + return nil + } + if failures > 0 { ui.Error(fmt.Sprintf("%d of %d file(s) failed.", failures, len(args))) return ui.ErrSilent @@ -63,52 +90,50 @@ type change struct { field, oldDisplay, newValue string } -func processFile(filename string, c *client.ConfluenceClient) bool { - prefix := "[" + filename + "]" +// processFile reconciles one file and returns a result. It performs no output; +// the caller renders the result. +func processFile(filename string, c *client.ConfluenceClient) *fixResult { + r := &fixResult{file: filename, dryRun: dryRun} mf, err := frontmatter.ParseFile(filename) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeValidation) } page, err := locatePage(mf.Frontmatter, c) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, locateCode(err)) } + r.pageID = page.ID // Read the live width to reconcile page_width; a read failure is non-fatal. liveWidth := "" if w, _, err := pagewidth.Read(c, page.ID); err != nil { - ui.Warn(prefix + " could not read page width: " + err.Error()) + r.warnings = append(r.warnings, "could not read page width: "+err.Error()) } else { liveWidth = string(w) } - changes := plannedChanges(mf.Frontmatter, page, liveWidth) - if len(changes) == 0 { - ui.Info(prefix + " already consistent") - return true - } - for _, ch := range changes { - verb := "set" - if dryRun { - verb = "would set" - } - ui.Info(fmt.Sprintf("%s %s %s: %s -> %s", prefix, verb, ch.field, ch.oldDisplay, ch.newValue)) + r.changes = plannedChanges(mf.Frontmatter, page, liveWidth) + if len(r.changes) == 0 { + r.ok = true + r.status = statusConsistent + return r } if dryRun { - return true + r.ok = true + r.status = statusChanged + return r } content := mf.Content - for _, ch := range changes { + for _, ch := range r.changes { content = frontmatter.UpdateField(content, ch.field, ch.newValue, "") } if err := os.WriteFile(filename, []byte(content), 0o644); err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeIO) } - return true + r.ok = true + r.status = statusChanged + return r } // locatePage finds the live page for a file: by page_id if present, else by diff --git a/cmd/fix/json.go b/cmd/fix/json.go new file mode 100644 index 0000000..4095f53 --- /dev/null +++ b/cmd/fix/json.go @@ -0,0 +1,158 @@ +package fix + +import ( + "errors" + "fmt" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/ui" +) + +// Per-file status verbs for fix. +const ( + statusChanged = "changed" + statusConsistent = "consistent" + statusFailed = "failed" +) + +// noneDisplay is the sentinel plannedChanges uses for a field with no prior +// value; it maps to a null "old" in JSON. +const noneDisplay = "(none)" + +// fixResult captures the outcome of reconciling one file. +type fixResult struct { + file string + ok bool + status string + pageID string + dryRun bool + changes []change + warnings []string + errMsg string + code jsonout.Code +} + +func (r *fixResult) fail(err error, code jsonout.Code) *fixResult { + r.ok = false + r.status = statusFailed + r.errMsg = err.Error() + r.code = code + return r +} + +// renderHuman reproduces the command's original inline output for one file. +func (r *fixResult) renderHuman() { + prefix := "[" + r.file + "]" + if !r.ok { + ui.Error(prefix + " " + r.errMsg) + return + } + for _, w := range r.warnings { + ui.Warn(prefix + " " + w) + } + if r.status == statusConsistent { + ui.Info(prefix + " already consistent") + return + } + verb := "set" + if r.dryRun { + verb = "would set" + } + for _, ch := range r.changes { + ui.Info(fmt.Sprintf("%s %s %s: %s -> %s", prefix, verb, ch.field, ch.oldDisplay, ch.newValue)) + } +} + +// jsonFixResult is fix's --json result shape. +type jsonFixResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + File string `json:"file"` + PageID *string `json:"page_id"` + DryRun bool `json:"dry_run"` + Changes []jsonChange `json:"changes"` + Warnings []string `json:"warnings"` + Error *string `json:"error"` + Code *jsonout.Code `json:"code"` +} + +// jsonChange is one reconciled field. old is null when there was no prior value. +type jsonChange struct { + Field string `json:"field"` + Old *string `json:"old"` + New string `json:"new"` +} + +func (r *fixResult) jsonResult() jsonFixResult { + res := jsonFixResult{ + OK: r.ok, + Status: r.status, + File: r.file, + PageID: nullableStr(r.pageID), + DryRun: r.dryRun, + Changes: toJSONChanges(r.changes), + Warnings: nonNilStrings(r.warnings), + } + if !r.ok { + res.Error = &r.errMsg + c := r.code + res.Code = &c + } + return res +} + +func toJSONChanges(changes []change) []jsonChange { + out := make([]jsonChange, 0, len(changes)) + for _, ch := range changes { + jc := jsonChange{Field: ch.field, New: ch.newValue} + if ch.oldDisplay != noneDisplay { + old := ch.oldDisplay + jc.Old = &old + } + out = append(out, jc) + } + return out +} + +// summarize builds fix's batch summary. +func summarize(results []*fixResult) map[string]int { + s := map[string]int{"total": len(results), "succeeded": 0, "failed": 0, "changed": 0, "consistent": 0} + for _, r := range results { + switch { + case !r.ok: + s["failed"]++ + case r.status == statusConsistent: + s["succeeded"]++ + s["consistent"]++ + default: + s["succeeded"]++ + s["changed"]++ + } + } + return s +} + +// locateCode classifies a page-location failure: an HTTP status maps via CodeFor, +// anything else is a frontmatter/target problem (VALIDATION). +func locateCode(err error) jsonout.Code { + var he *client.HTTPError + if errors.As(err, &he) { + return jsonout.CodeFor(err) + } + return jsonout.CodeValidation +} + +func nullableStr(s string) *string { + if s == "" { + return nil + } + return &s +} + +func nonNilStrings(s []string) []string { + if s == nil { + return []string{} + } + return s +} diff --git a/cmd/fix/json_test.go b/cmd/fix/json_test.go new file mode 100644 index 0000000..bada11d --- /dev/null +++ b/cmd/fix/json_test.go @@ -0,0 +1,121 @@ +package fix + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + results := []*fixResult{ + { + file: "docs/foo.md", ok: true, status: statusChanged, pageID: "123", + changes: []change{ + {field: "space", oldDisplay: "OLD", newValue: "ENG"}, + {field: "page_id", oldDisplay: noneDisplay, newValue: "123"}, + }, + warnings: []string{"could not read page width: boom"}, + }, + {file: "clean.md", ok: true, status: statusConsistent, pageID: "9"}, + (&fixResult{file: "bad.md"}).fail(errString("no page_id or title"), jsonout.CodeValidation), + } + items := make([]any, len(results)) + for i, r := range results { + items[i] = r.jsonResult() + } + env := jsonout.NewEnvelope("fix", items, summarize(results)) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestJSONResultChanged(t *testing.T) { + r := &fixResult{ + file: "docs/foo.md", ok: true, status: statusChanged, pageID: "123", + dryRun: false, + changes: []change{ + {field: "space", oldDisplay: "OLD", newValue: "ENG"}, + {field: "page_id", oldDisplay: noneDisplay, newValue: "123"}, + }, + } + got, err := json.MarshalIndent(r.jsonResult(), "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{ + "ok": true, + "status": "changed", + "file": "docs/foo.md", + "page_id": "123", + "dry_run": false, + "changes": [ + { + "field": "space", + "old": "OLD", + "new": "ENG" + }, + { + "field": "page_id", + "old": null, + "new": "123" + } + ], + "warnings": [], + "error": null, + "code": null +}` + if string(got) != want { + t.Errorf("changed result mismatch:\n got:\n%s\n want:\n%s", got, want) + } +} + +func TestJSONResultConsistent(t *testing.T) { + r := &fixResult{file: "f.md", ok: true, status: statusConsistent, pageID: "1"} + res := r.jsonResult() + if res.Status != "consistent" || res.Changes == nil || len(res.Changes) != 0 { + t.Errorf("consistent result unexpected: %+v", res) + } +} + +func TestJSONResultFailed(t *testing.T) { + r := (&fixResult{file: "f.md"}).fail(errString("no page_id or title"), jsonout.CodeValidation) + res := r.jsonResult() + if res.OK || res.Status != "failed" || res.PageID != nil { + t.Errorf("failed result unexpected: %+v", res) + } + if res.Error == nil || *res.Error != "no page_id or title" || + res.Code == nil || *res.Code != jsonout.CodeValidation { + t.Errorf("error/code not set: %+v", res) + } +} + +func TestSummarize(t *testing.T) { + s := summarize([]*fixResult{ + {ok: true, status: statusChanged}, + {ok: true, status: statusConsistent}, + {ok: false, status: statusFailed}, + }) + if s["total"] != 3 || s["succeeded"] != 2 || s["failed"] != 1 || + s["changed"] != 1 || s["consistent"] != 1 { + t.Errorf("summary = %+v", s) + } +} + +func TestLocateCode(t *testing.T) { + if got := locateCode(&client.HTTPError{StatusCode: 404}); got != jsonout.CodeNotFound { + t.Errorf("locateCode(404) = %q, want NOT_FOUND", got) + } + if got := locateCode(errString("no page_id or title")); got != jsonout.CodeValidation { + t.Errorf("locateCode(logic) = %q, want VALIDATION", got) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/cmd/info/info.go b/cmd/info/info.go index d5b201c..87c8eec 100644 --- a/cmd/info/info.go +++ b/cmd/info/info.go @@ -11,6 +11,7 @@ import ( "github.com/mozilla/markfluence/internal/client" "github.com/mozilla/markfluence/internal/frontmatter" + "github.com/mozilla/markfluence/internal/jsonout" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -42,28 +43,60 @@ func run(cmd *cobra.Command, args []string) error { envFile, _ := cmd.Flags().GetString("env-file") c, err := client.Resolve(url, username, envFile) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return fatalFail(err.Error(), jsonout.CodeConfig) } pageID, err := resolvePageID(args[0]) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return fatalFail(err.Error(), jsonout.CodeValidation) } page, err := c.GetPageOrNil(pageID) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return operationalFail(pageID, err, jsonout.CodeFor(err)) } if page == nil { - ui.Error(fmt.Sprintf("page %s not found", pageID)) - return ui.ErrSilent + return operationalFail(pageID, fmt.Errorf("page %s not found", pageID), jsonout.CodeNotFound) } - fmt.Println(formatPage(page, c, showProperties)) + + rep := buildReport(page, c, showProperties) + if ui.IsJSON() { + env := jsonout.NewEnvelope("info", []any{rep.jsonResult()}, + map[string]int{"total": 1, "succeeded": 1, "failed": 0}) + if err := jsonout.Emit(os.Stdout, env); err != nil { + return err + } + return nil + } + fmt.Println(rep.human()) return nil } +// fatalFail reports a config/usage/pre-flight failure: a JSON error object on +// stderr under --json, else a human error line, exiting 2. +func fatalFail(msg string, code jsonout.Code) error { + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, "info", msg, code) + } else { + ui.Error(msg) + } + return ui.SilentExit(2) +} + +// operationalFail reports an operational failure for the single target: under +// --json a results[0] entry {ok:false,error,code}, else a human error line, +// exiting 1. +func operationalFail(pageID string, err error, code jsonout.Code) error { + if ui.IsJSON() { + res := map[string]any{"ok": false, "page_id": pageID, "error": err.Error(), "code": code} + env := jsonout.NewEnvelope("info", []any{res}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1}) + _ = jsonout.Emit(os.Stdout, env) + } else { + ui.Error(err.Error()) + } + return ui.SilentExit(1) +} + // resolvePageID resolves the CLI argument to a page id: a markdown file's // frontmatter page_id, or a bare numeric id. func resolvePageID(arg string) (string, error) { @@ -83,14 +116,29 @@ func resolvePageID(arg string) (string, error) { return "", fmt.Errorf("%s is not a file or a numeric page id", arg) } -// formatPage builds the aligned "label: value" report for a page. -func formatPage(page *client.Page, c *client.ConfluenceClient, withProps bool) string { - spaceKey := client.SpaceKeyFromWebUI(page.Links.WebUI) - parent := page.ParentID - if parent == "" { - parent = "none (top-level)" - } +// report is the resolved metadata for a page, feeding both the human "label: +// value" renderer and the JSON result. Fields are captured raw (empty when +// absent); each renderer decides how to present or omit them. +type report struct { + id, title, status, space string + parentID string // "" for a top-level page + versionNum int + widthKnown bool + width jsonout.PageWidth + createdAt, creator string + creatorID string + updatedAt, editor string + editorID string + message, url string + withProps bool + properties []client.Property + propsErr error +} +// buildReport resolves a page (and, when withProps is set, its content +// properties) into a report. Author names and page width are fetched here; a +// width-fetch failure is tolerated (widthKnown stays false). +func buildReport(page *client.Page, c *client.ConfluenceClient, withProps bool) report { url := page.Links.Base + page.Links.WebUI if page.Links.WebUI == "" { url = fmt.Sprintf("%s/wiki/pages/viewpage.action?pageId=%s", c.BaseURL(), page.ID) @@ -99,75 +147,97 @@ func formatPage(page *client.Page, c *client.ConfluenceClient, withProps bool) s } cache := map[string]string{} - creator := authorName(c, page.AuthorID, cache) - editor := authorName(c, page.Version.AuthorID, cache) + r := report{ + id: page.ID, + title: page.Title, + status: page.Status, + space: client.SpaceKeyFromWebUI(page.Links.WebUI), + parentID: page.ParentID, + versionNum: page.Version.Number, + createdAt: page.CreatedAt, + creator: authorName(c, page.AuthorID, cache), + creatorID: page.AuthorID, + updatedAt: page.Version.CreatedAt, + editor: authorName(c, page.Version.AuthorID, cache), + editorID: page.Version.AuthorID, + message: page.Version.Message, + url: url, + withProps: withProps, + } + + var ( + width pagewidth.Width + explicit bool + err error + ) + if withProps { + r.properties, err = c.ListContentProperties(page.ID) + r.propsErr = err + if err == nil { + width, explicit = pagewidth.WidthFromProperties(r.properties) + } + } else { + width, explicit, err = pagewidth.Read(c, page.ID) + } + if err == nil { + r.widthKnown = true + r.width = jsonout.PageWidth{Value: string(width), Default: !explicit} + } + return r +} - pageWidth, properties, propsErr := resolveWidth(c, page.ID, withProps) +// human builds the aligned "label: value" report (empty fields omitted). +func (r report) human() string { + widthDisplay := "unknown" + if r.widthKnown { + widthDisplay = r.width.Value + if r.width.Default { + widthDisplay += " (Confluence default)" + } + } + parent := r.parentID + if parent == "" { + parent = "none (top-level)" + } rows := [][2]string{ - {"id", page.ID}, - {"title", page.Title}, - {"status", page.Status}, - {"space", spaceKey}, + {"id", r.id}, + {"title", r.title}, + {"status", r.status}, + {"space", r.space}, {"parent", parent}, - {"version", versionNumber(page.Version.Number)}, - {"page_width", pageWidth}, - {"created", withAuthor(page.CreatedAt, creator)}, - {"updated", withAuthor(page.Version.CreatedAt, editor)}, - {"message", page.Version.Message}, - {"url", url}, + {"version", versionNumber(r.versionNum)}, + {"page_width", widthDisplay}, + {"created", withAuthor(r.createdAt, r.creator)}, + {"updated", withAuthor(r.updatedAt, r.editor)}, + {"message", r.message}, + {"url", r.url}, } labelWidth := 0 - for _, r := range rows { - if len(r[0]) > labelWidth { - labelWidth = len(r[0]) + for _, row := range rows { + if len(row[0]) > labelWidth { + labelWidth = len(row[0]) } } labelWidth++ // room for the ':' var b strings.Builder - for _, r := range rows { - if r[1] == "" { + for _, row := range rows { + if row[1] == "" { continue } if b.Len() > 0 { b.WriteByte('\n') } - fmt.Fprintf(&b, "%-*s %s", labelWidth, r[0]+":", r[1]) + fmt.Fprintf(&b, "%-*s %s", labelWidth, row[0]+":", row[1]) } - if withProps { + if r.withProps { b.WriteByte('\n') - b.WriteString(propertiesSection(properties, propsErr)) + b.WriteString(propertiesSection(r.properties, r.propsErr)) } return b.String() } -// resolveWidth derives the page_width display string and, when withProps is set, -// the full property list. A fetch failure is tolerated (width "unknown"). -func resolveWidth(c *client.ConfluenceClient, pageID string, withProps bool) (string, []client.Property, error) { - var ( - props []client.Property - width pagewidth.Width - explicit bool - err error - ) - if withProps { - props, err = c.ListContentProperties(pageID) - if err == nil { - width, explicit = pagewidth.WidthFromProperties(props) - } - } else { - width, explicit, err = pagewidth.Read(c, pageID) - } - if err != nil { - return "unknown", nil, err - } - if explicit { - return string(width), props, nil - } - return string(width) + " (Confluence default)", props, nil -} - func propertiesSection(properties []client.Property, err error) string { if err != nil { return fmt.Sprintf("content properties: (could not fetch: %s)", err) diff --git a/cmd/info/json.go b/cmd/info/json.go new file mode 100644 index 0000000..7703825 --- /dev/null +++ b/cmd/info/json.go @@ -0,0 +1,96 @@ +package info + +import ( + "sort" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" +) + +// jsonInfoResult is info's --json result shape. Keys are always present (per the +// stable-schema rule); optional fetches that failed and top-level pages surface +// as null. page_status is Confluence's page status, renamed to avoid colliding +// with the action commands' result-status concept. +type jsonInfoResult struct { + OK bool `json:"ok"` + PageID string `json:"page_id"` + Title string `json:"title"` + PageStatus string `json:"page_status"` + Space string `json:"space"` + Parent *string `json:"parent"` + Version jsonVersion `json:"version"` + PageWidth *jsonout.PageWidth `json:"page_width"` + Created *jsonout.Stamp `json:"created"` + Updated *jsonout.Stamp `json:"updated"` + Message string `json:"message"` + URL string `json:"url"` + Properties []jsonProperty `json:"properties"` +} + +type jsonVersion struct { + Number int `json:"number"` +} + +type jsonProperty struct { + Key string `json:"key"` + Value any `json:"value"` +} + +// jsonResult renders the report as info's JSON result. +func (r report) jsonResult() jsonInfoResult { + res := jsonInfoResult{ + OK: true, + PageID: r.id, + Title: r.title, + PageStatus: r.status, + Space: r.space, + Parent: nullable(r.parentID), + Version: jsonVersion{Number: r.versionNum}, + Created: stamp(r.createdAt, r.creatorID, r.creator), + Updated: stamp(r.updatedAt, r.editorID, r.editor), + Message: r.message, + URL: r.url, + } + if r.widthKnown { + w := r.width + res.PageWidth = &w + } + // properties stays null unless --properties was given and the fetch succeeded; + // then it is a (possibly empty) sorted array. + if r.withProps && r.propsErr == nil { + res.Properties = propertyList(r.properties) + } + return res +} + +// nullable maps an empty string to a JSON null, else a pointer to the value. +func nullable(s string) *string { + if s == "" { + return nil + } + return &s +} + +// stamp builds a Stamp, returning nil when there is no timestamp. The author is +// nil when no account id is known. +func stamp(at, accountID, name string) *jsonout.Stamp { + if at == "" { + return nil + } + s := &jsonout.Stamp{At: at} + if accountID != "" { + s.By = &jsonout.Author{AccountID: accountID, Name: name} + } + return s +} + +// propertyList converts properties to sorted JSON entries. A non-nil (possibly +// empty) slice marshals as [], signalling "fetched, here they are". +func propertyList(props []client.Property) []jsonProperty { + out := make([]jsonProperty, len(props)) + for i, p := range props { + out[i] = jsonProperty{Key: p.Key, Value: p.Value} + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/cmd/info/json_test.go b/cmd/info/json_test.go new file mode 100644 index 0000000..999cfad --- /dev/null +++ b/cmd/info/json_test.go @@ -0,0 +1,142 @@ +package info + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func fullReport() report { + return report{ + id: "123", + title: "Foo", + status: "current", + space: "ENG", + parentID: "456", + versionNum: 7, + widthKnown: true, + width: jsonout.PageWidth{Value: "max", Default: true}, + createdAt: "2026-07-01T00:00:00Z", + creator: "Ada", + creatorID: "acc-1", + updatedAt: "2026-07-20T12:00:00Z", + editor: "Bo", + editorID: "acc-2", + message: "Updated via markfluence", + url: "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo", + } +} + +func marshal(t *testing.T, v any) string { + t.Helper() + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func TestJSONResultFull(t *testing.T) { + got := marshal(t, fullReport().jsonResult()) + want := `{ + "ok": true, + "page_id": "123", + "title": "Foo", + "page_status": "current", + "space": "ENG", + "parent": "456", + "version": { + "number": 7 + }, + "page_width": { + "value": "max", + "default": true + }, + "created": { + "at": "2026-07-01T00:00:00Z", + "by": { + "account_id": "acc-1", + "name": "Ada" + } + }, + "updated": { + "at": "2026-07-20T12:00:00Z", + "by": { + "account_id": "acc-2", + "name": "Bo" + } + }, + "message": "Updated via markfluence", + "url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo", + "properties": null +}` + if got != want { + t.Errorf("jsonResult mismatch:\n got:\n%s\n want:\n%s", got, want) + } +} + +func TestJSONResultTopLevelAndUnknownWidth(t *testing.T) { + r := fullReport() + r.parentID = "" // top-level + r.widthKnown = false // width read failed + res := r.jsonResult() + if res.Parent != nil { + t.Errorf("Parent = %v, want nil for top-level", *res.Parent) + } + if res.PageWidth != nil { + t.Errorf("PageWidth = %v, want nil when width unknown", *res.PageWidth) + } +} + +func TestJSONResultPropertiesGating(t *testing.T) { + // Not requested: null. + if r := fullReport().jsonResult(); r.Properties != nil { + t.Errorf("Properties = %v, want nil when --properties absent", r.Properties) + } + // Requested and empty: [] (non-nil). + r := fullReport() + r.withProps = true + r.properties = nil + if got := r.jsonResult().Properties; got == nil { + t.Errorf("Properties = nil, want [] when --properties given") + } + // Requested with values: sorted. + r.properties = []client.Property{{Key: "z", Value: "1"}, {Key: "a", Value: "2"}} + props := r.jsonResult().Properties + if len(props) != 2 || props[0].Key != "a" || props[1].Key != "z" { + t.Errorf("Properties not sorted by key: %+v", props) + } +} + +func TestJSONResultNoAuthorWhenIDMissing(t *testing.T) { + r := fullReport() + r.creatorID = "" // timestamp present, author unknown + res := r.jsonResult() + if res.Created == nil || res.Created.By != nil { + t.Errorf("Created = %+v, want stamp with nil By", res.Created) + } +} + +// TestSchemaConformance validates real info envelopes (success and the +// operational-failure single-target shape) against the published JSON Schema. +func TestSchemaConformance(t *testing.T) { + success := jsonout.NewEnvelope("info", []any{fullReport().jsonResult()}, + map[string]int{"total": 1, "succeeded": 1, "failed": 0}) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, success); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + failRes := map[string]any{"ok": false, "page_id": "999", "error": "page 999 not found", "code": jsonout.CodeNotFound} + failEnv := jsonout.NewEnvelope("info", []any{failRes}, map[string]int{"total": 1, "succeeded": 0, "failed": 1}) + buf.Reset() + if err := jsonout.Emit(&buf, failEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} diff --git a/cmd/read/json.go b/cmd/read/json.go new file mode 100644 index 0000000..f1644dc --- /dev/null +++ b/cmd/read/json.go @@ -0,0 +1,45 @@ +package read + +import ( + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/pagewidth" +) + +// jsonReadResult is read's --json result: the page's frontmatter fields as +// structured keys plus the body as its own string, with the requested format +// echoed. parent is null for a top-level page; page_width is null when the +// (best-effort) width read fails. +type jsonReadResult struct { + OK bool `json:"ok"` + PageID string `json:"page_id"` + Title string `json:"title"` + Space string `json:"space"` + Parent *string `json:"parent"` + PageWidth *jsonout.PageWidth `json:"page_width"` + Format string `json:"format"` + Body string `json:"body"` +} + +// buildResult assembles the JSON result. Unlike the human path, the metadata is +// carried in structured fields (not embedded YAML), so body is the bare content +// for both formats. A width read is attempted regardless of format so the schema +// is stable; failure leaves page_width null. +func buildResult(c *client.ConfluenceClient, page *client.Page, format, body string) jsonReadResult { + res := jsonReadResult{ + OK: true, + PageID: page.ID, + Title: page.Title, + Space: client.SpaceKeyFromWebUI(page.Links.WebUI), + Format: format, + Body: body, + } + if page.ParentID != "" { + p := page.ParentID + res.Parent = &p + } + if w, explicit, err := pagewidth.Read(c, page.ID); err == nil { + res.PageWidth = &jsonout.PageWidth{Value: string(w), Default: !explicit} + } + return res +} diff --git a/cmd/read/json_test.go b/cmd/read/json_test.go new file mode 100644 index 0000000..9ac3623 --- /dev/null +++ b/cmd/read/json_test.go @@ -0,0 +1,85 @@ +package read + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + parent := "456" + res := jsonReadResult{ + OK: true, PageID: "123", Title: "X", Space: "ENG", Parent: &parent, + PageWidth: &jsonout.PageWidth{Value: "max", Default: true}, + Format: "markdown", Body: "# X\n\nhello", + } + env := jsonout.NewEnvelope("read", []any{res}, map[string]int{"total": 1, "succeeded": 1, "failed": 0}) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) + + failRes := map[string]any{"ok": false, "page_id": "9", "error": "page 9 not found", "code": jsonout.CodeNotFound} + failEnv := jsonout.NewEnvelope("read", []any{failRes}, map[string]int{"total": 1, "succeeded": 0, "failed": 1}) + buf.Reset() + if err := jsonout.Emit(&buf, failEnv); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestJSONReadResultMarshal(t *testing.T) { + parent := "456" + res := jsonReadResult{ + OK: true, + PageID: "123", + Title: "X", + Space: "ENG", + Parent: &parent, + PageWidth: &jsonout.PageWidth{Value: "max", Default: true}, + Format: "markdown", + Body: "# X\n\nhello", + } + b, err := json.MarshalIndent(res, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{ + "ok": true, + "page_id": "123", + "title": "X", + "space": "ENG", + "parent": "456", + "page_width": { + "value": "max", + "default": true + }, + "format": "markdown", + "body": "# X\n\nhello" +}` + if string(b) != want { + t.Errorf("read result mismatch:\n got:\n%s\n want:\n%s", b, want) + } +} + +func TestJSONReadResultTopLevelNullWidth(t *testing.T) { + res := jsonReadResult{OK: true, PageID: "1", Format: "storage", Body: "
"} + b, err := json.Marshal(res) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var round map[string]any + if err := json.Unmarshal(b, &round); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if round["parent"] != nil { + t.Errorf("parent = %v, want null", round["parent"]) + } + if round["page_width"] != nil { + t.Errorf("page_width = %v, want null", round["page_width"]) + } +} diff --git a/cmd/read/read.go b/cmd/read/read.go index 3cef193..5a4bfbf 100644 --- a/cmd/read/read.go +++ b/cmd/read/read.go @@ -5,12 +5,14 @@ package read import ( "fmt" "net/url" + "os" "regexp" "strings" "github.com/mozilla/markfluence/internal/client" "github.com/mozilla/markfluence/internal/convert" "github.com/mozilla/markfluence/internal/frontmatter" + "github.com/mozilla/markfluence/internal/jsonout" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -45,15 +47,13 @@ func init() { func run(cmd *cobra.Command, args []string) error { if formatFlag != formatMarkdown && formatFlag != formatStorage { - ui.Error(fmt.Sprintf("unsupported --format %q (supported: %s, %s)", - formatFlag, formatMarkdown, formatStorage)) - return ui.ErrSilent + return fatalFail(fmt.Sprintf("unsupported --format %q (supported: %s, %s)", + formatFlag, formatMarkdown, formatStorage), jsonout.CodeValidation) } pageID, err := parsePageID(args[0]) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return fatalFail(err.Error(), jsonout.CodeValidation) } url, _ := cmd.Flags().GetString("url") @@ -61,38 +61,68 @@ func run(cmd *cobra.Command, args []string) error { envFile, _ := cmd.Flags().GetString("env-file") c, err := client.Resolve(url, username, envFile) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return fatalFail(err.Error(), jsonout.CodeConfig) } page, err := c.GetPageBodyOrNil(pageID) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + return operationalFail(pageID, err, jsonout.CodeFor(err)) } if page == nil { - ui.Error(fmt.Sprintf("page %s not found", pageID)) - return ui.ErrSilent + return operationalFail(pageID, fmt.Errorf("page %s not found", pageID), jsonout.CodeNotFound) } if page.Body.Storage.Value == "" { - ui.Error(fmt.Sprintf( + return operationalFail(pageID, fmt.Errorf( "page %s has no readable body (it may be a folder or an unsupported content type)", - pageID)) - return ui.ErrSilent + pageID), jsonout.CodeValidation) + } + + body := page.Body.Storage.Value + if formatFlag == formatMarkdown { + body, err = convert.StorageToMarkdown(page.Body.Storage.Value) + if err != nil { + return operationalFail(pageID, err, jsonout.CodeConvert) + } + } + + if ui.IsJSON() { + env := jsonout.NewEnvelope("read", []any{buildResult(c, page, formatFlag, body)}, + map[string]int{"total": 1, "succeeded": 1, "failed": 0}) + return jsonout.Emit(os.Stdout, env) } if formatFlag == formatStorage { - fmt.Println(page.Body.Storage.Value) + fmt.Println(body) return nil } + fmt.Print(frontmatterBlock(c, page) + "\n" + body) + return nil +} - body, err := convert.StorageToMarkdown(page.Body.Storage.Value) - if err != nil { +// fatalFail reports a config/usage/pre-flight failure: a JSON error object on +// stderr under --json, else a human error line, exiting 2. +func fatalFail(msg string, code jsonout.Code) error { + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, "read", msg, code) + } else { + ui.Error(msg) + } + return ui.SilentExit(2) +} + +// operationalFail reports an operational failure for the single target: under +// --json a results[0] entry {ok:false,error,code}, else a human error line, +// exiting 1. +func operationalFail(pageID string, err error, code jsonout.Code) error { + if ui.IsJSON() { + res := map[string]any{"ok": false, "page_id": pageID, "error": err.Error(), "code": code} + env := jsonout.NewEnvelope("read", []any{res}, + map[string]int{"total": 1, "succeeded": 0, "failed": 1}) + _ = jsonout.Emit(os.Stdout, env) + } else { ui.Error(err.Error()) - return ui.ErrSilent } - fmt.Print(frontmatterBlock(c, page) + "\n" + body) - return nil + return ui.SilentExit(1) } // frontmatterBlock builds the YAML frontmatter prefix for markdown output: diff --git a/cmd/root.go b/cmd/root.go index eb652d2..a3fc52c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,9 +2,9 @@ package cmd import ( - "errors" "fmt" "os" + "strings" "github.com/mozilla/markfluence/cmd/create" "github.com/mozilla/markfluence/cmd/fix" @@ -12,6 +12,7 @@ import ( "github.com/mozilla/markfluence/cmd/read" "github.com/mozilla/markfluence/cmd/update" "github.com/mozilla/markfluence/internal/buildinfo" + "github.com/mozilla/markfluence/internal/jsonout" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" ) @@ -22,6 +23,7 @@ var ( envFileFlag string debugFlag bool noColorFlag bool + jsonFlag bool ) var rootCmd = &cobra.Command{ @@ -40,6 +42,7 @@ var rootCmd = &cobra.Command{ } } ui.SetDebug(debugFlag) + ui.SetJSON(jsonFlag) return nil }, // Bare `markfluence` prints help; subcommands carry the work. @@ -52,16 +55,43 @@ var rootCmd = &cobra.Command{ SilenceErrors: true, } -// Execute runs the root command, exiting non-zero on error. Errors a command -// already reported (ui.ErrSilent) are not printed again; any other error -// reaching here is cobra-generated (e.g. bad args/flags) and is printed. +// Execute runs the root command, exiting non-zero on error. A failure a command +// already reported (a silent error) is not printed again and exits with its +// carried code (1 operational, 2 config/usage). Any other error is +// cobra-generated (bad args/flags): a usage error, printed as a human line or a +// JSON error object under --json, exiting 2. func Execute() { + // Detect --json before parsing so that even a flag-parse failure (which + // short-circuits PersistentPreRunE, where SetJSON normally runs) is reported + // as a JSON error object rather than a stray human line on stderr. + if jsonRequested(os.Args[1:]) { + ui.SetJSON(true) + } if err := rootCmd.Execute(); err != nil { - if !errors.Is(err, ui.ErrSilent) { + if ui.IsSilent(err) { + os.Exit(ui.ExitCode(err)) + } + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, "", err.Error(), jsonout.CodeConfig) + } else { ui.Error(err.Error()) } - os.Exit(1) + os.Exit(2) + } +} + +// jsonRequested reports whether the raw args request --json (bare, or +// --json=true), independent of cobra parsing. --json=false is honored as off. +func jsonRequested(args []string) bool { + for _, a := range args { + switch { + case a == "--json": + return true + case strings.HasPrefix(a, "--json="): + return strings.TrimPrefix(a, "--json=") != "false" + } } + return false } func init() { @@ -75,6 +105,8 @@ func init() { "Enable verbose debug output") rootCmd.PersistentFlags().BoolVar(&noColorFlag, "no-color", false, "Disable colored output") + rootCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, + "Emit machine-readable JSON to stdout instead of human output") rootCmd.PersistentFlags().SortFlags = false // Append a docs footer to every command's --help output. Subcommands inherit diff --git a/cmd/root_test.go b/cmd/root_test.go index 1e78221..a5dbfa4 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -9,7 +9,7 @@ func TestRootCommandWiring(t *testing.T) { if rootCmd.Use != "markfluence" { t.Errorf("rootCmd.Use = %q, want %q", rootCmd.Use, "markfluence") } - for _, flag := range []string{"url", "debug", "no-color"} { + for _, flag := range []string{"url", "debug", "no-color", "json"} { if rootCmd.PersistentFlags().Lookup(flag) == nil { t.Errorf("persistent flag --%s not registered", flag) } @@ -17,7 +17,7 @@ func TestRootCommandWiring(t *testing.T) { } func TestSubcommandsRegistered(t *testing.T) { - want := map[string]bool{"update": false, "create": false, "fix": false, "info": false} + want := map[string]bool{"update": false, "create": false, "fix": false, "info": false, "read": false} for _, c := range rootCmd.Commands() { delete(want, c.Name()) } diff --git a/cmd/update/json.go b/cmd/update/json.go new file mode 100644 index 0000000..c05afef --- /dev/null +++ b/cmd/update/json.go @@ -0,0 +1,163 @@ +package update + +import ( + "fmt" + + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/ui" +) + +// Per-file status verbs for update. +const ( + statusPublished = "published" + statusSkipped = "skipped" + statusFailed = "failed" +) + +// updateResult captures the outcome of publishing one file. It carries both what +// the human renderer needs (to reproduce the previous inline output) and the +// structured fields for JSON. +type updateResult struct { + file string + ok bool + status string + pageID string + title string + space string + url string + versionPrev int + versionNew int + width *jsonout.PageWidth // set only when a width was asserted this run + widthSet bool // a "page width:" line should show (human) + attachments []jsonout.Attachment + broken []string + warnings []string + errMsg string + code jsonout.Code +} + +// fail marks the result failed with an error and code, and returns it for a +// tidy `return r.fail(...)`. +func (r *updateResult) fail(err error, code jsonout.Code) *updateResult { + r.ok = false + r.status = statusFailed + r.errMsg = err.Error() + r.code = code + return r +} + +// renderHuman reproduces the command's original inline output for one file, in +// the original order (broken/warnings, attachments, the update line, an optional +// width line, then the success/skip/error line). +func (r *updateResult) renderHuman() { + prefix := "[" + r.file + "]" + if !r.ok { + ui.Error(prefix + " " + r.errMsg) + return + } + if r.status == statusSkipped { + ui.Info(prefix + " Skipping -- no changes") + return + } + for _, b := range r.broken { + ui.Warn(prefix + " " + b) + } + for _, w := range r.warnings { + ui.Warn(prefix + " " + w) + } + for _, a := range r.attachments { + ui.Info(fmt.Sprintf("%s attachment %s: %s", prefix, a.Action, a.Filename)) + } + ui.Info(fmt.Sprintf("%s Updating '%s' (v%d -> v%d)...", prefix, r.title, r.versionPrev, r.versionNew)) + if r.widthSet && r.width != nil { + ui.Info(prefix + " page width: " + r.width.Value) + } + ui.Success(fmt.Sprintf("%s Published v%d: %s", prefix, r.versionNew, r.url)) +} + +// jsonUpdateResult is update's --json result shape. +type jsonUpdateResult struct { + OK bool `json:"ok"` + Status string `json:"status"` + File string `json:"file"` + PageID *string `json:"page_id"` + Title *string `json:"title"` + Space *string `json:"space"` + URL *string `json:"url"` + Version *jsonUpdateVersion `json:"version"` + PageWidth *jsonout.PageWidth `json:"page_width"` + Attachments []jsonout.Attachment `json:"attachments"` + Warnings []string `json:"warnings"` + Broken []string `json:"broken"` + Error *string `json:"error"` + Code *jsonout.Code `json:"code"` +} + +type jsonUpdateVersion struct { + Previous int `json:"previous"` + New int `json:"new"` +} + +func (r *updateResult) jsonResult() jsonUpdateResult { + res := jsonUpdateResult{ + OK: r.ok, + Status: r.status, + File: r.file, + PageID: strOrNil(r.pageID), + Title: strOrNil(r.title), + Space: strOrNil(r.space), + URL: strOrNil(r.url), + PageWidth: r.width, + Attachments: nonNilAttachments(r.attachments), + Warnings: nonNilStrings(r.warnings), + Broken: nonNilStrings(r.broken), + } + // version is present once we know the live version (all non-early failures). + if r.versionPrev != 0 || r.versionNew != 0 { + res.Version = &jsonUpdateVersion{Previous: r.versionPrev, New: r.versionNew} + } + if !r.ok { + res.Error = &r.errMsg + c := r.code + res.Code = &c + } + return res +} + +// summarize builds update's batch summary. +func summarize(results []*updateResult) map[string]int { + s := map[string]int{"total": len(results), "succeeded": 0, "failed": 0, "skipped": 0} + for _, r := range results { + switch { + case !r.ok: + s["failed"]++ + case r.status == statusSkipped: + s["succeeded"]++ + s["skipped"]++ + default: + s["succeeded"]++ + } + } + return s +} + +func strOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +func nonNilStrings(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +func nonNilAttachments(a []jsonout.Attachment) []jsonout.Attachment { + if a == nil { + return []jsonout.Attachment{} + } + return a +} diff --git a/cmd/update/json_test.go b/cmd/update/json_test.go new file mode 100644 index 0000000..818561b --- /dev/null +++ b/cmd/update/json_test.go @@ -0,0 +1,128 @@ +package update + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mozilla/markfluence/internal/jsonout" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestSchemaConformance(t *testing.T) { + results := []*updateResult{ + { + file: "docs/foo.md", ok: true, status: statusPublished, + pageID: "123", title: "Foo", space: "ENG", url: "https://x/123", + versionPrev: 3, versionNew: 4, + width: &jsonout.PageWidth{Value: "max", Default: false}, + attachments: []jsonout.Attachment{{Action: "updated", Filename: "d.png"}}, + }, + (&updateResult{file: "bad.md"}).fail(errTest("boom"), jsonout.CodeValidation), + } + items := make([]any, len(results)) + for i, r := range results { + items[i] = r.jsonResult() + } + env := jsonout.NewEnvelope("update", items, summarize(results)) + var buf bytes.Buffer + if err := jsonout.Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + schematest.ValidateEnvelope(t, buf.Bytes()) +} + +func TestJSONResultPublished(t *testing.T) { + r := &updateResult{ + file: "docs/foo.md", ok: true, status: statusPublished, + pageID: "123", title: "Foo", space: "ENG", + url: "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo", + versionPrev: 3, versionNew: 4, + width: &jsonout.PageWidth{Value: "max", Default: false}, + attachments: []jsonout.Attachment{{Action: "updated", Filename: "d.png"}}, + } + got, err := json.MarshalIndent(r.jsonResult(), "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{ + "ok": true, + "status": "published", + "file": "docs/foo.md", + "page_id": "123", + "title": "Foo", + "space": "ENG", + "url": "https://wiki.example.net/wiki/spaces/ENG/pages/123/Foo", + "version": { + "previous": 3, + "new": 4 + }, + "page_width": { + "value": "max", + "default": false + }, + "attachments": [ + { + "action": "updated", + "filename": "d.png" + } + ], + "warnings": [], + "broken": [], + "error": null, + "code": null +}` + if string(got) != want { + t.Errorf("published result mismatch:\n got:\n%s\n want:\n%s", got, want) + } +} + +func TestJSONResultSkipped(t *testing.T) { + r := &updateResult{ + file: "f.md", ok: true, status: statusSkipped, + pageID: "1", title: "T", space: "ENG", url: "u", + versionPrev: 5, versionNew: 5, + } + res := r.jsonResult() + if res.Status != "skipped" || res.Version == nil || + res.Version.Previous != 5 || res.Version.New != 5 { + t.Errorf("skipped result unexpected: %+v", res) + } +} + +func TestJSONResultFailedEarly(t *testing.T) { + r := (&updateResult{file: "f.md"}).fail(errTest("no page id"), jsonout.CodeValidation) + res := r.jsonResult() + if res.OK || res.Status != "failed" { + t.Errorf("want failed result, got %+v", res) + } + if res.Version != nil { + t.Errorf("Version = %+v, want nil for an early failure", res.Version) + } + if res.PageID != nil { + t.Errorf("PageID = %v, want nil (never resolved)", *res.PageID) + } + if res.Error == nil || *res.Error != "no page id" || res.Code == nil || *res.Code != jsonout.CodeValidation { + t.Errorf("error/code not set: %+v", res) + } + // Empty slices, not null. + if res.Attachments == nil || res.Warnings == nil || res.Broken == nil { + t.Errorf("array fields must marshal as [], got %+v", res) + } +} + +func TestSummarize(t *testing.T) { + results := []*updateResult{ + {ok: true, status: statusPublished}, + {ok: true, status: statusSkipped}, + {ok: false, status: statusFailed}, + } + s := summarize(results) + if s["total"] != 3 || s["succeeded"] != 2 || s["failed"] != 1 || s["skipped"] != 1 { + t.Errorf("summary = %+v", s) + } +} + +type errTest string + +func (e errTest) Error() string { return string(e) } diff --git a/cmd/update/update.go b/cmd/update/update.go index ff60e7d..5379864 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -3,6 +3,7 @@ package update import ( + "errors" "fmt" "os" "strings" @@ -12,6 +13,7 @@ import ( "github.com/mozilla/markfluence/internal/client" "github.com/mozilla/markfluence/internal/convert" "github.com/mozilla/markfluence/internal/frontmatter" + "github.com/mozilla/markfluence/internal/jsonout" "github.com/mozilla/markfluence/internal/pagewidth" "github.com/mozilla/markfluence/internal/ui" "github.com/spf13/cobra" @@ -61,16 +63,42 @@ func run(cmd *cobra.Command, args []string) error { envFile, _ := cmd.Flags().GetString("env-file") c, err := client.Resolve(url, username, envFile) if err != nil { - ui.Error(err.Error()) - return ui.ErrSilent + if ui.IsJSON() { + _ = jsonout.EmitError(os.Stderr, "update", err.Error(), jsonout.CodeConfig) + } else { + ui.Error(err.Error()) + } + return ui.SilentExit(2) } failures := 0 + results := make([]*updateResult, 0, len(args)) for _, filename := range args { - if !processFile(filename, c) { + r := processFile(filename, c) + results = append(results, r) + if !ui.IsJSON() { + r.renderHuman() + } + if !r.ok { failures++ } } + + if ui.IsJSON() { + items := make([]any, len(results)) + for i, r := range results { + items[i] = r.jsonResult() + } + env := jsonout.NewEnvelope("update", items, summarize(results)) + if err := jsonout.Emit(os.Stdout, env); err != nil { + return err + } + if failures > 0 { + return ui.SilentExit(1) + } + return nil + } + if failures > 0 { ui.Error(fmt.Sprintf("%d of %d file(s) failed.", failures, len(args))) return ui.ErrSilent @@ -78,87 +106,92 @@ func run(cmd *cobra.Command, args []string) error { return nil } -func processFile(filename string, c *client.ConfluenceClient) bool { - prefix := "[" + filename + "]" +// processFile publishes one file and returns a result describing the outcome. It +// performs no output itself; the caller renders the result (human lines or JSON). +func processFile(filename string, c *client.ConfluenceClient) *updateResult { + r := &updateResult{file: filename} mf, err := frontmatter.ParseFile(filename) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeValidation) } title, pageID := resolveTitlePageID(titleFlag, pageIDFlag, mf) if pageID == "" { - ui.Error(prefix + " no page id: set page_id in frontmatter or pass --page-id") - return false + return r.fail(errors.New("no page id: set page_id in frontmatter or pass --page-id"), + jsonout.CodeValidation) } + r.pageID = pageID width, applyWidth, err := resolveWidth(pageWidthFlag, mf) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeValidation) } page, err := c.GetPage(pageID) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeFor(err)) } - spaceKey := client.SpaceKeyFromWebUI(page.Links.WebUI) + r.space = client.SpaceKeyFromWebUI(page.Links.WebUI) if title == "" { title = page.Title // fall back to the live page's title } + r.title = title + r.versionPrev = page.Version.Number + r.url = pageURL(c, page, pageID) if !force && page.Version.CreatedAt != "" { if pageUpdated, err := time.Parse(time.RFC3339, page.Version.CreatedAt); err == nil { if info, err := os.Stat(filename); err == nil && !info.ModTime().After(pageUpdated) { - ui.Info(prefix + " Skipping -- no changes") - return true + r.ok = true + r.status = statusSkipped + r.versionNew = page.Version.Number + return r } } } - pageContent, err := convert.MdToConfluence(mf, c.BaseURL(), spaceKey, buildinfo.Stamp()) + pageContent, err := convert.MdToConfluence(mf, c.BaseURL(), r.space, buildinfo.Stamp()) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false - } - for _, msg := range append(append([]string{}, pageContent.Broken...), pageContent.Warnings...) { - ui.Warn(prefix + " " + msg) + return r.fail(err, jsonout.CodeConvert) } + r.broken = append(r.broken, pageContent.Broken...) + r.warnings = append(r.warnings, pageContent.Warnings...) actions, err := c.SyncAttachments(pageID, toLocalAttachments(pageContent.Attachments)) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeFor(err)) } for _, a := range actions { - ui.Info(fmt.Sprintf("%s attachment %s: %s", prefix, a.Action, a.Filename)) + r.attachments = append(r.attachments, jsonout.Attachment{Action: a.Action, Filename: a.Filename}) } next := page.Version.Number + 1 - ui.Info(fmt.Sprintf("%s Updating '%s' (v%d -> v%d)...", prefix, title, page.Version.Number, next)) result, err := c.UpdatePage(pageID, title, pageContent.HTML, next, message) if err != nil { - ui.Error(prefix + " " + err.Error()) - return false + return r.fail(err, jsonout.CodeFor(err)) } + r.versionNew = next + r.url = pageURL(c, result, pageID) // Assert the page width (a separate content-property call) only when set; - // non-fatal. + // non-fatal (a failure is a warning, not an error). if applyWidth { + r.width = &jsonout.PageWidth{Value: string(width), Default: false} if acts, err := pagewidth.Apply(c, pageID, width); err != nil { - ui.Warn(prefix + " could not set page width: " + err.Error()) + r.width = nil + r.warnings = append(r.warnings, "could not set page width: "+err.Error()) } else { for _, a := range acts { if a.Action == "set" { - ui.Info(prefix + " page width: " + string(width)) + r.widthSet = true break } } } } - ui.Success(fmt.Sprintf("%s Published v%d: %s", prefix, next, pageURL(c, result, pageID))) - return true + r.ok = true + r.status = statusPublished + return r } // overrideNeedsSingleFile reports whether a per-page override (--title/--page-id) diff --git a/go.mod b/go.mod index cceb497..3a9bdc6 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25 require ( github.com/charmbracelet/lipgloss v1.1.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/yuin/goldmark v1.8.4 ) @@ -23,4 +24,5 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 568f1e6..9763d1b 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -25,6 +27,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -39,4 +43,6 @@ golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/jsonout/jsonout.go b/internal/jsonout/jsonout.go new file mode 100644 index 0000000..29c2daa --- /dev/null +++ b/internal/jsonout/jsonout.go @@ -0,0 +1,120 @@ +// Package jsonout builds markfluence's machine-readable --json output: the +// stable envelope wrapping every command's results, the typed error object for +// fatal failures, and the error-code vocabulary shared by both. +// +// The stdout payload is a single Envelope; fatal/pre-flight failures are an +// ErrorObject on stderr. "Stable schema" is per-command: each command always +// emits the same keys with the same shapes, and SchemaVersion is bumped on any +// breaking change. +package jsonout + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "github.com/mozilla/markfluence/internal/buildinfo" + "github.com/mozilla/markfluence/internal/client" +) + +// SchemaVersion is the version of the JSON output schema. Bump it on any +// breaking change to the envelope or a per-command result shape. +const SchemaVersion = 1 + +// Code is a typed, machine-branchable error category. +type Code string + +// Error codes, tailored to markfluence's failure sites. +const ( + CodeConfig Code = "CONFIG" // credential/config resolution, usage + CodeAuth Code = "AUTH" // 401/403 + CodeNotFound Code = "NOT_FOUND" // 404 + CodeValidation Code = "VALIDATION" // bad frontmatter/args, duplicate title + CodeConvert Code = "CONVERT" // MdToConfluence / StorageToMarkdown failure + CodeIO Code = "IO" // local file read/write + CodeNetwork Code = "NETWORK" // transport failure (no HTTP status) + CodeAPI Code = "API" // other HTTP >= 400 +) + +// Envelope is the top-level stdout document for every command in --json mode. +type Envelope struct { + SchemaVersion int `json:"schema_version"` + MarkfluenceVersion string `json:"markfluence_version"` + Command string `json:"command"` + Results []any `json:"results"` + Summary any `json:"summary"` +} + +// ErrorObject is the stderr document for a fatal/pre-flight failure in --json +// mode (bad flags, credential resolution). No stdout payload accompanies it. +type ErrorObject struct { + SchemaVersion int `json:"schema_version"` + Command string `json:"command"` + Error string `json:"error"` + Code Code `json:"code"` +} + +// NewEnvelope builds an envelope for a command, stamping the schema and build +// version. results is emitted as [] (never null) when empty. +func NewEnvelope(command string, results []any, summary any) Envelope { + if results == nil { + results = []any{} + } + return Envelope{ + SchemaVersion: SchemaVersion, + MarkfluenceVersion: buildinfo.Version, + Command: command, + Results: results, + Summary: summary, + } +} + +// Emit writes the envelope as pretty-printed (2-space) JSON with a trailing +// newline. It is the sole writer of stdout in --json mode. +func Emit(w io.Writer, env Envelope) error { + return encode(w, env) +} + +// EmitError writes a typed error object (pretty-printed, trailing newline), +// intended for stderr on a fatal/pre-flight failure. +func EmitError(w io.Writer, command, msg string, code Code) error { + return encode(w, ErrorObject{ + SchemaVersion: SchemaVersion, + Command: command, + Error: msg, + Code: code, + }) +} + +func encode(w io.Writer, v any) error { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + _, err = w.Write(b) + return err +} + +// CodeFor classifies an error into a Code. An *HTTPError maps by status +// (401/403 -> AUTH, 404 -> NOT_FOUND, else API); any other non-nil error is +// treated as a transport/NETWORK failure. Callers with more context (a bad +// frontmatter parse, a local file error) should pass an explicit Code instead. +func CodeFor(err error) Code { + var he *client.HTTPError + if errors.As(err, &he) { + switch he.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return CodeAuth + case http.StatusNotFound: + return CodeNotFound + default: + return CodeAPI + } + } + if err != nil { + return CodeNetwork + } + return CodeAPI +} diff --git a/internal/jsonout/jsonout_test.go b/internal/jsonout/jsonout_test.go new file mode 100644 index 0000000..6f2c8cd --- /dev/null +++ b/internal/jsonout/jsonout_test.go @@ -0,0 +1,98 @@ +package jsonout + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/mozilla/markfluence/internal/client" + "github.com/mozilla/markfluence/internal/schematest" +) + +func TestErrorObjectSchemaConformance(t *testing.T) { + var buf bytes.Buffer + if err := EmitError(&buf, "update", "could not resolve credentials", CodeConfig); err != nil { + t.Fatalf("EmitError: %v", err) + } + schematest.ValidateError(t, buf.Bytes()) + + // A pre-parse (bad-flag) error carries an empty command. + buf.Reset() + if err := EmitError(&buf, "", "unknown flag: --bogus", CodeConfig); err != nil { + t.Fatalf("EmitError: %v", err) + } + schematest.ValidateError(t, buf.Bytes()) +} + +func TestEmitEnvelope(t *testing.T) { + var buf bytes.Buffer + env := NewEnvelope("info", []any{map[string]any{"ok": true}}, map[string]any{"total": 1}) + if err := Emit(&buf, env); err != nil { + t.Fatalf("Emit: %v", err) + } + out := buf.String() + if !strings.HasSuffix(out, "\n") { + t.Errorf("output not newline-terminated: %q", out) + } + if !strings.Contains(out, "\n \"command\": \"info\"") { + t.Errorf("output not 2-space indented:\n%s", out) + } + for _, want := range []string{`"schema_version": 1`, `"command": "info"`, `"results"`, `"summary"`} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestNewEnvelopeEmptyResultsIsArray(t *testing.T) { + var buf bytes.Buffer + if err := Emit(&buf, NewEnvelope("fix", nil, nil)); err != nil { + t.Fatalf("Emit: %v", err) + } + if !strings.Contains(buf.String(), `"results": []`) { + t.Errorf("empty results should marshal as [], got:\n%s", buf.String()) + } +} + +func TestEmitError(t *testing.T) { + var buf bytes.Buffer + if err := EmitError(&buf, "update", "could not resolve credentials", CodeConfig); err != nil { + t.Fatalf("EmitError: %v", err) + } + out := buf.String() + for _, want := range []string{ + `"schema_version": 1`, + `"command": "update"`, + `"error": "could not resolve credentials"`, + `"code": "CONFIG"`, + } { + if !strings.Contains(out, want) { + t.Errorf("error output missing %q:\n%s", want, out) + } + } +} + +func TestCodeFor(t *testing.T) { + tests := []struct { + name string + err error + want Code + }{ + {"401", &client.HTTPError{StatusCode: 401}, CodeAuth}, + {"403", &client.HTTPError{StatusCode: 403}, CodeAuth}, + {"404", &client.HTTPError{StatusCode: 404}, CodeNotFound}, + {"500", &client.HTTPError{StatusCode: 500}, CodeAPI}, + {"wrapped 404", errWrap(&client.HTTPError{StatusCode: 404}), CodeNotFound}, + {"transport", errors.New("dial tcp: connection refused"), CodeNetwork}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CodeFor(tt.err); got != tt.want { + t.Errorf("CodeFor(%v) = %q, want %q", tt.err, got, tt.want) + } + }) + } +} + +func errWrap(err error) error { return errors.Join(errors.New("context"), err) } diff --git a/internal/jsonout/types.go b/internal/jsonout/types.go new file mode 100644 index 0000000..b943d92 --- /dev/null +++ b/internal/jsonout/types.go @@ -0,0 +1,32 @@ +package jsonout + +// Shared value objects used across command result shapes. Compound values are +// always structured objects, never human display strings. Per-command result +// structs live in their own packages; only the genuinely shared pieces are here. + +// PageWidth is the structured page_width value: the effective width plus whether +// it is the Confluence default (i.e. not explicitly set on the page). +type PageWidth struct { + Value string `json:"value"` + Default bool `json:"default"` +} + +// Author identifies a Confluence user by account id and resolved display name. +// Name may be empty when the lookup fails. +type Author struct { + AccountID string `json:"account_id"` + Name string `json:"name"` +} + +// Stamp is a timestamped authorship record (created/updated). By is nil when no +// author is known. +type Stamp struct { + At string `json:"at"` + By *Author `json:"by"` +} + +// Attachment is a synced attachment action: "created" or "updated" for a file. +type Attachment struct { + Action string `json:"action"` + Filename string `json:"filename"` +} diff --git a/internal/schematest/schematest.go b/internal/schematest/schematest.go new file mode 100644 index 0000000..5e9d8d3 --- /dev/null +++ b/internal/schematest/schematest.go @@ -0,0 +1,92 @@ +// Package schematest is a test-only helper that validates markfluence's --json +// output against the published JSON Schema (schema/json-output/v1.json). It is +// the drift guard: because the schema uses additionalProperties:false +// throughout, any new field on a result struct fails validation until the schema +// is updated to match. +package schematest + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "sync" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +const schemaID = "https://github.com/mozilla/markfluence/schema/json-output/v1.json" + +var ( + once sync.Once + envelope *jsonschema.Schema + errObject *jsonschema.Schema + compileErr error +) + +// schemaPath resolves the schema file relative to this source file, so tests +// find it regardless of which package's directory they run in. +func schemaPath() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "schema", "json-output", "v1.json") +} + +func compile() { + data, err := os.ReadFile(schemaPath()) + if err != nil { + compileErr = err + return + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + compileErr = err + return + } + c := jsonschema.NewCompiler() + if err := c.AddResource(schemaID, doc); err != nil { + compileErr = err + return + } + if envelope, err = c.Compile(schemaID); err != nil { + compileErr = err + return + } + errObject, err = c.Compile(schemaID + "#/$defs/errorObject") + compileErr = err +} + +func load(t *testing.T) { + t.Helper() + once.Do(compile) + if compileErr != nil { + t.Fatalf("compiling JSON Schema: %v", compileErr) + } +} + +// ValidateEnvelope fails the test if instance (a marshaled --json stdout +// document) does not conform to the envelope schema. +func ValidateEnvelope(t *testing.T, instance []byte) { + t.Helper() + load(t) + validate(t, envelope, instance) +} + +// ValidateError fails the test if instance (a marshaled stderr error object) +// does not conform to #/$defs/errorObject. +func ValidateError(t *testing.T, instance []byte) { + t.Helper() + load(t) + validate(t, errObject, instance) +} + +func validate(t *testing.T, sch *jsonschema.Schema, instance []byte) { + t.Helper() + v, err := jsonschema.UnmarshalJSON(bytes.NewReader(instance)) + if err != nil { + t.Fatalf("instance is not valid JSON: %v\n%s", err, instance) + } + if err := sch.Validate(v); err != nil { + t.Errorf("instance does not conform to schema:\n%v\n--- instance ---\n%s", err, instance) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index e904ebe..91f98fb 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -11,13 +11,51 @@ import ( "github.com/charmbracelet/lipgloss" ) -// ErrSilent marks a failure a command has already reported (via Error). The root -// exits non-zero on it without printing anything further; any other error -// reaching the root is cobra-generated (bad args/flags) and is printed. -var ErrSilent = errors.New("reported") +// silentErr marks a failure a command has already reported (via Error, or via a +// JSON payload/error object). The root exits with the carried code without +// printing anything further. +type silentErr struct{ code int } + +func (e *silentErr) Error() string { return "reported" } + +// ErrSilent marks a reported failure with the default operational exit code (1). +// Any other error reaching the root is cobra-generated (bad args/flags). +var ErrSilent error = &silentErr{code: 1} + +// SilentExit returns a reported-failure error carrying a specific exit code +// (1 = operational failure, 2 = config/usage/pre-flight). +func SilentExit(code int) error { return &silentErr{code: code} } + +// IsSilent reports whether err is a reported (silent) failure. +func IsSilent(err error) bool { + var s *silentErr + return errors.As(err, &s) +} + +// ExitCode returns the exit code carried by a silent error, or 1 for any other +// non-nil error. +func ExitCode(err error) int { + var s *silentErr + if errors.As(err, &s) { + return s.code + } + return 1 +} var debug bool +// jsonMode, when set, silences the stdout helpers (Header/Success/Info/Dim) and +// the stderr Warn/Error lines: in --json mode all of that content is carried in +// the structured payload / error object instead, and stdout must stay valid +// JSON. Debug is exempt (it is --debug-gated and goes to stderr). +var jsonMode bool + +// SetJSON enables or disables JSON mode. See jsonMode. +func SetJSON(v bool) { jsonMode = v } + +// IsJSON reports whether JSON mode is active. +func IsJSON() bool { return jsonMode } + // IsPiped reports whether stdout is piped (not a terminal). var IsPiped = func() bool { fi, err := os.Stdout.Stat() @@ -41,33 +79,53 @@ var ( bold = lipgloss.NewStyle().Bold(true) ) -// Header prints a bold section heading. +// Header prints a bold section heading. No-op in JSON mode. func Header(msg string) { + if jsonMode { + return + } fmt.Println("\n" + bold.Render(msg)) } -// Success prints a green check line. +// Success prints a green check line. No-op in JSON mode. func Success(msg string) { + if jsonMode { + return + } fmt.Println(green.Render(" ✓ ") + msg) } -// Warn prints a yellow warning line to stderr. +// Warn prints a yellow warning line to stderr. No-op in JSON mode (warnings are +// carried in the payload instead). func Warn(msg string) { + if jsonMode { + return + } fmt.Fprintln(os.Stderr, yellow.Render(" ! ")+msg) } -// Error prints a red error line to stderr. +// Error prints a red error line to stderr. No-op in JSON mode (errors are +// carried in the payload / error object instead). func Error(msg string) { + if jsonMode { + return + } fmt.Fprintln(os.Stderr, red.Render(" ✗ ")+msg) } -// Info prints a plain info line. +// Info prints a plain info line. No-op in JSON mode. func Info(msg string) { + if jsonMode { + return + } fmt.Println(" " + msg) } -// Dim prints a dimmed line. +// Dim prints a dimmed line. No-op in JSON mode. func Dim(msg string) { + if jsonMode { + return + } fmt.Println(gray.Render(" " + msg)) } diff --git a/schema/json-output/v1.json b/schema/json-output/v1.json new file mode 100644 index 0000000..4c228f6 --- /dev/null +++ b/schema/json-output/v1.json @@ -0,0 +1,328 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/mozilla/markfluence/schema/json-output/v1.json", + "title": "markfluence --json output (schema_version 1)", + "description": "The single JSON document markfluence writes to stdout under --json. results holds one object per target (a single element for info/read); its item shape and the summary shape depend on command. The typed error object written to stderr on a fatal/pre-flight failure conforms to #/$defs/errorObject.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "markfluence_version", "command", "results", "summary"], + "properties": { + "schema_version": { "const": 1 }, + "markfluence_version": { "type": "string" }, + "command": { "enum": ["info", "read", "update", "create", "fix"] }, + "results": { "type": "array" }, + "summary": { "type": "object" } + }, + "allOf": [ + { + "if": { "properties": { "command": { "const": "info" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { "items": { "oneOf": [{ "$ref": "#/$defs/infoResult" }, { "$ref": "#/$defs/singleOpFailure" }] } }, + "summary": { "$ref": "#/$defs/basicSummary" } + } + } + }, + { + "if": { "properties": { "command": { "const": "read" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { "items": { "oneOf": [{ "$ref": "#/$defs/readResult" }, { "$ref": "#/$defs/singleOpFailure" }] } }, + "summary": { "$ref": "#/$defs/basicSummary" } + } + } + }, + { + "if": { "properties": { "command": { "const": "update" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { "items": { "$ref": "#/$defs/updateResult" } }, + "summary": { "$ref": "#/$defs/updateSummary" } + } + } + }, + { + "if": { "properties": { "command": { "const": "create" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { "items": { "$ref": "#/$defs/createResult" } }, + "summary": { "$ref": "#/$defs/createSummary" } + } + } + }, + { + "if": { "properties": { "command": { "const": "fix" } }, "required": ["command"] }, + "then": { + "properties": { + "results": { "items": { "$ref": "#/$defs/fixResult" } }, + "summary": { "$ref": "#/$defs/fixSummary" } + } + } + } + ], + "$defs": { + "code": { + "enum": ["CONFIG", "AUTH", "NOT_FOUND", "VALIDATION", "CONVERT", "IO", "NETWORK", "API"] + }, + "codeOrNull": { + "oneOf": [{ "$ref": "#/$defs/code" }, { "type": "null" }] + }, + "stringOrNull": { + "type": ["string", "null"] + }, + "pageWidth": { + "type": "object", + "additionalProperties": false, + "required": ["value", "default"], + "properties": { + "value": { "type": "string" }, + "default": { "type": "boolean" } + } + }, + "pageWidthOrNull": { + "oneOf": [{ "$ref": "#/$defs/pageWidth" }, { "type": "null" }] + }, + "author": { + "type": "object", + "additionalProperties": false, + "required": ["account_id", "name"], + "properties": { + "account_id": { "type": "string" }, + "name": { "type": "string" } + } + }, + "stamp": { + "type": "object", + "additionalProperties": false, + "required": ["at", "by"], + "properties": { + "at": { "type": "string" }, + "by": { "oneOf": [{ "$ref": "#/$defs/author" }, { "type": "null" }] } + } + }, + "stampOrNull": { + "oneOf": [{ "$ref": "#/$defs/stamp" }, { "type": "null" }] + }, + "attachment": { + "type": "object", + "additionalProperties": false, + "required": ["action", "filename"], + "properties": { + "action": { "type": "string" }, + "filename": { "type": "string" } + } + }, + "singleOpFailure": { + "description": "An operational failure for a single-target command (info/read): page not found, fetch error, etc.", + "type": "object", + "additionalProperties": false, + "required": ["ok", "page_id", "error", "code"], + "properties": { + "ok": { "const": false }, + "page_id": { "type": "string" }, + "error": { "type": "string" }, + "code": { "$ref": "#/$defs/code" } + } + }, + "infoResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", "page_id", "title", "page_status", "space", "parent", + "version", "page_width", "created", "updated", "message", "url", "properties" + ], + "properties": { + "ok": { "const": true }, + "page_id": { "type": "string" }, + "title": { "type": "string" }, + "page_status": { "type": "string" }, + "space": { "type": "string" }, + "parent": { "$ref": "#/$defs/stringOrNull" }, + "version": { + "type": "object", + "additionalProperties": false, + "required": ["number"], + "properties": { "number": { "type": "integer" } } + }, + "page_width": { "$ref": "#/$defs/pageWidthOrNull" }, + "created": { "$ref": "#/$defs/stampOrNull" }, + "updated": { "$ref": "#/$defs/stampOrNull" }, + "message": { "type": "string" }, + "url": { "type": "string" }, + "properties": { + "oneOf": [ + { "type": "null" }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["key", "value"], + "properties": { "key": { "type": "string" }, "value": {} } + } + } + ] + } + } + }, + "readResult": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "page_id", "title", "space", "parent", "page_width", "format", "body"], + "properties": { + "ok": { "const": true }, + "page_id": { "type": "string" }, + "title": { "type": "string" }, + "space": { "type": "string" }, + "parent": { "$ref": "#/$defs/stringOrNull" }, + "page_width": { "$ref": "#/$defs/pageWidthOrNull" }, + "format": { "enum": ["markdown", "storage"] }, + "body": { "type": "string" } + } + }, + "updateResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", "status", "file", "page_id", "title", "space", "url", + "version", "page_width", "attachments", "warnings", "broken", "error", "code" + ], + "properties": { + "ok": { "type": "boolean" }, + "status": { "enum": ["published", "skipped", "failed"] }, + "file": { "type": "string" }, + "page_id": { "$ref": "#/$defs/stringOrNull" }, + "title": { "$ref": "#/$defs/stringOrNull" }, + "space": { "$ref": "#/$defs/stringOrNull" }, + "url": { "$ref": "#/$defs/stringOrNull" }, + "version": { + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["previous", "new"], + "properties": { "previous": { "type": "integer" }, "new": { "type": "integer" } } + } + ] + }, + "page_width": { "$ref": "#/$defs/pageWidthOrNull" }, + "attachments": { "type": "array", "items": { "$ref": "#/$defs/attachment" } }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "broken": { "type": "array", "items": { "type": "string" } }, + "error": { "$ref": "#/$defs/stringOrNull" }, + "code": { "$ref": "#/$defs/codeOrNull" } + } + }, + "createResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", "status", "file", "page_id", "title", "space", "parent", "url", + "page_width", "persisted", "attachments", "warnings", "broken", "error", "code" + ], + "properties": { + "ok": { "type": "boolean" }, + "status": { "enum": ["created", "not_created", "failed"] }, + "file": { "type": "string" }, + "page_id": { "$ref": "#/$defs/stringOrNull" }, + "title": { "$ref": "#/$defs/stringOrNull" }, + "space": { "$ref": "#/$defs/stringOrNull" }, + "parent": { "$ref": "#/$defs/stringOrNull" }, + "url": { "$ref": "#/$defs/stringOrNull" }, + "page_width": { "$ref": "#/$defs/pageWidthOrNull" }, + "persisted": { "type": "boolean" }, + "attachments": { "type": "array", "items": { "$ref": "#/$defs/attachment" } }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "broken": { "type": "array", "items": { "type": "string" } }, + "error": { "$ref": "#/$defs/stringOrNull" }, + "code": { "$ref": "#/$defs/codeOrNull" } + } + }, + "fixResult": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "status", "file", "page_id", "dry_run", "changes", "warnings", "error", "code"], + "properties": { + "ok": { "type": "boolean" }, + "status": { "enum": ["changed", "consistent", "failed"] }, + "file": { "type": "string" }, + "page_id": { "$ref": "#/$defs/stringOrNull" }, + "dry_run": { "type": "boolean" }, + "changes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field", "old", "new"], + "properties": { + "field": { "type": "string" }, + "old": { "$ref": "#/$defs/stringOrNull" }, + "new": { "type": "string" } + } + } + }, + "warnings": { "type": "array", "items": { "type": "string" } }, + "error": { "$ref": "#/$defs/stringOrNull" }, + "code": { "$ref": "#/$defs/codeOrNull" } + } + }, + "basicSummary": { + "description": "info/read batch summary (always total:1).", + "type": "object", + "additionalProperties": false, + "required": ["total", "succeeded", "failed"], + "properties": { + "total": { "type": "integer" }, + "succeeded": { "type": "integer" }, + "failed": { "type": "integer" } + } + }, + "updateSummary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "succeeded", "failed", "skipped"], + "properties": { + "total": { "type": "integer" }, + "succeeded": { "type": "integer" }, + "failed": { "type": "integer" }, + "skipped": { "type": "integer" } + } + }, + "createSummary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "succeeded", "failed", "aborted"], + "properties": { + "total": { "type": "integer" }, + "succeeded": { "type": "integer" }, + "failed": { "type": "integer" }, + "aborted": { "type": "boolean" } + } + }, + "fixSummary": { + "type": "object", + "additionalProperties": false, + "required": ["total", "succeeded", "failed", "changed", "consistent"], + "properties": { + "total": { "type": "integer" }, + "succeeded": { "type": "integer" }, + "failed": { "type": "integer" }, + "changed": { "type": "integer" }, + "consistent": { "type": "integer" } + } + }, + "errorObject": { + "description": "The typed error object written to stderr on a fatal/pre-flight failure. command may be empty for a pre-parse (bad-flag) error.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "command", "error", "code"], + "properties": { + "schema_version": { "const": 1 }, + "command": { "type": "string" }, + "error": { "type": "string" }, + "code": { "$ref": "#/$defs/code" } + } + } + } +}