From 165ce749ab2886f052debea3eac6a57e0e8c57a0 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:32:35 -0400 Subject: [PATCH 1/4] feat(cli-generator): derive CLI group help from OpenAPI tags and keep help lines concise (#17476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli-generator): use OpenAPI root tag descriptions for CLI group help Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli-generator): preserve group descriptions in agent skills Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(cli-generator): refine OpenAPI group descriptions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(cli): improve OpenAPI group help descriptions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli-generator): keep method detail in long help and stop cutting help at abbreviations ` --help` rendered only the truncated table line, so the rest of an operation's description was unreachable anywhere in the CLI. Methods now set `long_about` with the fuller prose, capped at CLI_DESCRIPTION_LIMIT so a verbose spec cannot flood the terminal. The table line stays a single short sentence, and `long_about` is omitted when it would only repeat it. Sentence splitting was a bare `find(". ")`, so `e.g.`, `i.e.` and `U.S.` cut help text mid-phrase — on every method line, since the 80-char table cap now routes all descriptions through it. `first_sentence` and the pre-existing `find_last_sentence_boundary` (which does its own boundary search on the truncation path, and had the same bug) now share an `is_sentence_boundary` predicate rejecting dotted abbreviations, single-letter initials, a known abbreviation list, and any period followed by a lowercase word. Regenerates the vendored CLI runtime copies under seed/. Co-Authored-By: Claude * fix(cli-generator): stop a sibling tag from naming a group it only partly covers Found testing against ElevenLabs' real spec: the `voices` group rendered "Create and manage Professional Voice Clones (PVCs)." Its operations carry both `voices` (12 ops, no root description) and `pvc-voices` (14 ops, documented, exclusive to the group), so `pvc-voices` cleared the coverage majority and named the whole group after one of its features. Two rules, both narrowing what can be borrowed: - A tag named after the group owns that group's description. When the group's operations declare it, it is the only tag allowed to describe them — the name-match loop above already returned if it carries prose, so reaching the coverage branch means the group genuinely documents nothing and must fall back to the generic label rather than borrow from a sibling. - A described tag the group declares less often than another describes part of the group, so it cannot name the whole group even when it clears the coverage majority. Verified against the customer spec: exactly one group line changes, the wrong one. The remaining non-generic lines are unchanged. Co-Authored-By: Claude * feat(cli-generator): let x-fern-groups description drive the group table line `description` alone (no `summary`) left the command table showing `Operations on ''`, with the prose reachable only from `--help`. That asymmetry stopped making sense once tag prose could reach the table: a group with no Fern configuration at all now gets a real table line from its tag, while a group whose owner had written a description explicitly did not. `description` is the more authoritative of the two, so it now resolves ahead of tag prose for `about` — the precedence the agent-skill emitter already applied, so the CLI table and the emitted skill no longer disagree. Only the first sentence reaches the table; `long_about` keeps the full text and is omitted when it would merely repeat it. This also gives users a lever that does not disturb Docs or SDKs: in `buildServices.ts`, `summary` sets a subpackage's `display-name` (a rename) while `description` sets its `docs`, so a description-only override corrects group help everywhere without relabelling anything. Reverses the behavior pinned by test_group_description_sets_long_about_only from #16034; the test is retained under a new name recording the rationale. Co-Authored-By: Claude * feat(cli-generator): keep an operation's prose instead of only its summary The parser collapsed two distinct OpenAPI fields into one: let description = operation.summary.clone() .or_else(|| operation.description.clone()); `RestMethod` carried a single `description`, and both the command table entry and the command's own `--help` body were derived from it. When an operation had a terse `summary` and a richer `description` the prose was dropped at parse time and was unreachable anywhere in the CLI — so the `long_about` added earlier in this branch had nothing better to show than the table line. `RestMethod` now carries `long_description` alongside it, holding the operation's `description` when it says more than the summary, and `None` when it would only duplicate it. The table line is unchanged; ` --help` renders the prose. Capped at a new `CLI_LONG_DESCRIPTION_LIMIT` (600) rather than the 200-char `CLI_DESCRIPTION_LIMIT` used for flag help: this body only appears when the user asks for one specific command. Measured against a real 336-operation spec, 200 would cut 23 descriptions mid-sentence while 600 covers 333 of 335. On that spec, 80 of 128 top-level leaf commands now show prose under `--help` that previously appeared nowhere. Co-Authored-By: Claude * fix(cli-generator): make -h a summary again instead of a second --help `-h` and `--help` printed the same flag help, so a request with 40 documented fields rendered ~9.6k characters either way and neither view was scannable. clap already gives two tiers; the generator was only filling one. Flags now set `help` to a one-line form (first sentence, capped at CLI_SHORT_DESCRIPTION_LIMIT) and `long_help` to the fuller prose, with the long form omitted when it would only repeat the short one. Annotations — availability badge, `(api: )`, `[default: ...]` — are applied to both tiers through one shared closure rather than being duplicated. Two related defects in the same path: - `build_multipart_field_arg` set `.help(desc.clone())` with no truncation, while every other parameter went through `truncate_description`. A single multipart operation could emit several hundred characters for one flag. - Spec prose carrying hand-indentation from YAML block scalars reached the help column verbatim, showing as long gaps mid-sentence. `collapse_whitespace` now normalizes it. Measured on a real 46-flag multipart operation: `-h` 9,602 -> 3,657 chars, `--help` 10,089 -> 6,345. Operations whose parameters already fit are unchanged. Co-Authored-By: Claude * fix(cli-generator): let --help keep a flag's full documentation The two-tier split capped the long tier at CLI_DESCRIPTION_LIMIT (200), which cut exactly the clauses that make `--help` worth reading — constraints, character limits, pricing surcharges, examples. `-h` is the tier that should trim; `--help` is the thorough one and should truncate nothing a real spec contains. CLI_LONG_DESCRIPTION_LIMIT is raised to 2000 and now bounds both a command's own description and its flags'. It is a guard against a pathological spec flooding the terminal, not an editorial trim: the longest parameter description in a real 885-parameter spec is 560 characters. Measured on `speech-to-text convert`, where 19 flags exceed the old 200-char cap: --keyterms -h A list of keyterms to bias the transcription towards. --help 628 chars, through "...a minimum billable duration of 20 seconds applies per request." `-h` stays at 3,657 chars (down from 9,602); `--help` is 10,224, close to the original 10,089 because that tier is meant to be complete. Co-Authored-By: Claude * fix(cli-generator): don't promote a description that only paraphrases the summary Keeping an operation's `description` for `--help` assumed it elaborates on `summary`. Often it just says the same thing differently: -h List workspace groups --help Get all groups in the workspace which reads as two different commands rather than a summary and its detail. On a real 336-operation spec this was the majority: 192 of 335 operations carrying both fields have a description within 40 characters of the summary. A description now earns the long slot only by adding a further sentence or a substantial clause (40+ characters). Otherwise `--help` shows the same header as `-h`, which is what it did before the field split. On that spec, 150 leaf commands still elaborate and 187 repeat the short line. This narrows f8966ca rather than reverting it: descriptions that genuinely document an operation — webhook semantics, deprecation notices, pricing — are still the ones that reach `--help`. Co-Authored-By: Claude * chore(cli-generator): regenerate cli seed fixtures Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Revert "chore(cli-generator): regenerate cli seed fixtures" This reverts commit 59b98bc8f5f7b1dfc7e93e48ba70a6204fe6b13f. --------- Co-authored-by: rishabh.dhadda Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude --- ...penapi-tag-descriptions-for-cli-groups.yml | 28 + generators/cli/sdk/src/app.rs | 52 +- generators/cli/sdk/src/openapi/app.rs | 123 ++ generators/cli/sdk/src/openapi/commands.rs | 1174 ++++++++++++++++- generators/cli/sdk/src/openapi/discovery.rs | 25 + generators/cli/sdk/src/openapi/parser.rs | 516 +++++++- .../cli/sdk/src/openapi/skill_emitter.rs | 114 +- generators/cli/sdk/src/text.rs | 166 ++- seed/cli/allof-inline/src/app.rs | 52 +- seed/cli/allof-inline/src/openapi/app.rs | 123 ++ seed/cli/allof-inline/src/openapi/commands.rs | 1174 ++++++++++++++++- .../cli/allof-inline/src/openapi/discovery.rs | 25 + seed/cli/allof-inline/src/openapi/parser.rs | 516 +++++++- .../allof-inline/src/openapi/skill_emitter.rs | 114 +- seed/cli/allof-inline/src/text.rs | 166 ++- seed/cli/allof/src/app.rs | 52 +- seed/cli/allof/src/openapi/app.rs | 123 ++ seed/cli/allof/src/openapi/commands.rs | 1174 ++++++++++++++++- seed/cli/allof/src/openapi/discovery.rs | 25 + seed/cli/allof/src/openapi/parser.rs | 516 +++++++- seed/cli/allof/src/openapi/skill_emitter.rs | 114 +- seed/cli/allof/src/text.rs | 166 ++- .../src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- .../with-split-type-crates/src/app.rs | 52 +- .../with-split-type-crates/src/openapi/app.rs | 145 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-split-type-crates/src/text.rs | 166 ++- .../cli-basic-auth/with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../no-custom-config/src/app.rs | 52 +- .../no-custom-config/src/openapi/app.rs | 123 ++ .../no-custom-config/src/openapi/commands.rs | 1174 ++++++++++++++++- .../no-custom-config/src/openapi/discovery.rs | 25 + .../no-custom-config/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../no-custom-config/src/text.rs | 166 ++- .../with-split-type-crates/src/app.rs | 52 +- .../with-split-type-crates/src/openapi/app.rs | 145 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-split-type-crates/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../no-custom-config/src/app.rs | 52 +- .../no-custom-config/src/openapi/app.rs | 123 ++ .../no-custom-config/src/openapi/commands.rs | 1174 ++++++++++++++++- .../no-custom-config/src/openapi/discovery.rs | 25 + .../no-custom-config/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../no-custom-config/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../cli-oauth/client-credentials/src/app.rs | 52 +- .../client-credentials/src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../client-credentials/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../cli-oauth/client-credentials/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../with-split-type-crates/src/app.rs | 52 +- .../with-split-type-crates/src/openapi/app.rs | 145 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-split-type-crates/src/text.rs | 166 ++- .../src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- seed/cli/imdb/src/app.rs | 52 +- seed/cli/imdb/src/openapi/app.rs | 123 ++ seed/cli/imdb/src/openapi/commands.rs | 1174 ++++++++++++++++- seed/cli/imdb/src/openapi/discovery.rs | 25 + seed/cli/imdb/src/openapi/parser.rs | 516 +++++++- seed/cli/imdb/src/openapi/skill_emitter.rs | 114 +- seed/cli/imdb/src/text.rs | 166 ++- .../inline-enum-type-name-override/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- .../multi-content-type-examples/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../multi-content-type-examples/src/text.rs | 166 ++- .../src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- seed/cli/no-content-response/src/app.rs | 52 +- .../no-content-response/src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../no-content-response/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/no-content-response/src/text.rs | 166 ++- seed/cli/null-type/src/app.rs | 52 +- seed/cli/null-type/src/openapi/app.rs | 123 ++ seed/cli/null-type/src/openapi/commands.rs | 1174 ++++++++++++++++- seed/cli/null-type/src/openapi/discovery.rs | 25 + seed/cli/null-type/src/openapi/parser.rs | 516 +++++++- .../null-type/src/openapi/skill_emitter.rs | 114 +- seed/cli/null-type/src/text.rs | 166 ++- seed/cli/nullable-allof-extends/src/app.rs | 52 +- .../nullable-allof-extends/src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/nullable-allof-extends/src/text.rs | 166 ++- seed/cli/nullable-request-body/src/app.rs | 52 +- .../nullable-request-body/src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/nullable-request-body/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- seed/cli/openapi-request-body-ref/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/openapi-request-body-ref/src/text.rs | 166 ++- seed/cli/openapi-subtitle/src/app.rs | 52 +- seed/cli/openapi-subtitle/src/openapi/app.rs | 123 ++ .../openapi-subtitle/src/openapi/commands.rs | 1174 ++++++++++++++++- .../openapi-subtitle/src/openapi/discovery.rs | 25 + .../openapi-subtitle/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/openapi-subtitle/src/text.rs | 166 ++- seed/cli/query-param-name-conflict/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../cli/query-param-name-conflict/src/text.rs | 166 ++- .../src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- .../github-distribution-app/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../github-distribution-app/src/text.rs | 166 ++- .../github-distribution/src/app.rs | 52 +- .../github-distribution/src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../github-distribution/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../github-distribution/src/text.rs | 166 ++- .../github-no-publish/src/app.rs | 52 +- .../github-no-publish/src/openapi/app.rs | 123 ++ .../github-no-publish/src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../github-no-publish/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../github-no-publish/src/text.rs | 166 ++- .../github-npm/src/app.rs | 52 +- .../github-npm/src/openapi/app.rs | 123 ++ .../github-npm/src/openapi/commands.rs | 1174 ++++++++++++++++- .../github-npm/src/openapi/discovery.rs | 25 + .../github-npm/src/openapi/parser.rs | 516 +++++++- .../github-npm/src/openapi/skill_emitter.rs | 114 +- .../github-npm/src/text.rs | 166 ++- .../no-custom-config/src/app.rs | 52 +- .../no-custom-config/src/openapi/app.rs | 123 ++ .../no-custom-config/src/openapi/commands.rs | 1174 ++++++++++++++++- .../no-custom-config/src/openapi/discovery.rs | 25 + .../no-custom-config/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../no-custom-config/src/text.rs | 166 ++- .../with-split-type-crates/src/app.rs | 52 +- .../with-split-type-crates/src/openapi/app.rs | 145 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-split-type-crates/src/text.rs | 166 ++- .../with-wire-tests/src/app.rs | 52 +- .../with-wire-tests/src/openapi/app.rs | 123 ++ .../with-wire-tests/src/openapi/commands.rs | 1174 ++++++++++++++++- .../with-wire-tests/src/openapi/discovery.rs | 25 + .../with-wire-tests/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../with-wire-tests/src/text.rs | 166 ++- .../respect-optional-request-body/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../respect-optional-request-body/src/text.rs | 166 ++- .../src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- .../cli/server-sent-events-openapi/src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../server-sent-events-openapi/src/text.rs | 166 ++- .../src/app.rs | 52 +- .../src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../src/text.rs | 166 ++- seed/cli/server-url-templating/src/app.rs | 52 +- .../server-url-templating/src/openapi/app.rs | 123 ++ .../src/openapi/commands.rs | 1174 ++++++++++++++++- .../src/openapi/discovery.rs | 25 + .../src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/server-url-templating/src/text.rs | 166 ++- seed/cli/url-form-encoded/src/app.rs | 52 +- seed/cli/url-form-encoded/src/openapi/app.rs | 123 ++ .../url-form-encoded/src/openapi/commands.rs | 1174 ++++++++++++++++- .../url-form-encoded/src/openapi/discovery.rs | 25 + .../url-form-encoded/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/url-form-encoded/src/text.rs | 166 ++- seed/cli/webhook-audience/src/app.rs | 52 +- seed/cli/webhook-audience/src/openapi/app.rs | 123 ++ .../webhook-audience/src/openapi/commands.rs | 1174 ++++++++++++++++- .../webhook-audience/src/openapi/discovery.rs | 25 + .../webhook-audience/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/webhook-audience/src/text.rs | 166 ++- seed/cli/x-fern-default/src/app.rs | 52 +- seed/cli/x-fern-default/src/openapi/app.rs | 123 ++ .../x-fern-default/src/openapi/commands.rs | 1174 ++++++++++++++++- .../x-fern-default/src/openapi/discovery.rs | 25 + seed/cli/x-fern-default/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- seed/cli/x-fern-default/src/text.rs | 166 ++- .../no-custom-config/src/app.rs | 52 +- .../no-custom-config/src/openapi/app.rs | 123 ++ .../no-custom-config/src/openapi/commands.rs | 1174 ++++++++++++++++- .../no-custom-config/src/openapi/discovery.rs | 25 + .../no-custom-config/src/openapi/parser.rs | 516 +++++++- .../src/openapi/skill_emitter.rs | 114 +- .../no-custom-config/src/text.rs | 166 ++- 337 files changed, 100148 insertions(+), 4128 deletions(-) create mode 100644 generators/cli/changes/unreleased/use-openapi-tag-descriptions-for-cli-groups.yml diff --git a/generators/cli/changes/unreleased/use-openapi-tag-descriptions-for-cli-groups.yml b/generators/cli/changes/unreleased/use-openapi-tag-descriptions-for-cli-groups.yml new file mode 100644 index 000000000000..ae5bf4fd881e --- /dev/null +++ b/generators/cli/changes/unreleased/use-openapi-tag-descriptions-for-cli-groups.yml @@ -0,0 +1,28 @@ +- summary: | + Use safe OpenAPI root tag descriptions for generated CLI group help and agent + skills when no x-fern-groups summary is provided. Match operation-declared + tags to groups without borrowing ambiguous or scope-style descriptions, and + keep CLI help table lines concise while preserving full prose in long help. + Treat summaries that only restate a group's command name as non-informative + when a usable tag description exists, while retaining them when no such + description is available. Preserve framework built-in group descriptions + when a colliding API group has only the generic fallback. Method help now + keeps the fuller description under ` --help` while the command + table stays a single short sentence, and sentence splitting no longer cuts + help text at abbreviations such as `e.g.`, `i.e.`, or `U.S.`. A tag named + after the group owns that group's description: when it documents nothing, + a sibling tag's prose no longer stands in for it, and a tag the group + declares less often than another never names the whole group. An operation's + `description` is no longer discarded in favour of its terse `summary`: the + summary still labels the command table while the prose is what the command + renders under its own `--help` — but only when it elaborates rather than + paraphrasing the summary, so the two tiers never read as different + commands. Flag help is split the same way — `-h` + shows a one-line form and `--help` the fuller prose — and multipart field + help, which previously bypassed truncation entirely, is now capped and has + its spec indentation collapsed like every other parameter. `--help` keeps + whatever the spec documents in full; only `-h` trims. An explicit + `x-fern-groups.description` now drives the group's command-table line too + (first sentence, with the full prose kept in long help) instead of only + populating long help while the table showed the generic label. + type: feat diff --git a/generators/cli/sdk/src/app.rs b/generators/cli/sdk/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/generators/cli/sdk/src/app.rs +++ b/generators/cli/sdk/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/generators/cli/sdk/src/openapi/app.rs b/generators/cli/sdk/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/generators/cli/sdk/src/openapi/app.rs +++ b/generators/cli/sdk/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/generators/cli/sdk/src/openapi/commands.rs b/generators/cli/sdk/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/generators/cli/sdk/src/openapi/commands.rs +++ b/generators/cli/sdk/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/generators/cli/sdk/src/openapi/discovery.rs b/generators/cli/sdk/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/generators/cli/sdk/src/openapi/discovery.rs +++ b/generators/cli/sdk/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/generators/cli/sdk/src/openapi/parser.rs b/generators/cli/sdk/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/generators/cli/sdk/src/openapi/parser.rs +++ b/generators/cli/sdk/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/generators/cli/sdk/src/openapi/skill_emitter.rs b/generators/cli/sdk/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/generators/cli/sdk/src/openapi/skill_emitter.rs +++ b/generators/cli/sdk/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/generators/cli/sdk/src/text.rs b/generators/cli/sdk/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/generators/cli/sdk/src/text.rs +++ b/generators/cli/sdk/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/allof-inline/src/app.rs b/seed/cli/allof-inline/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/allof-inline/src/app.rs +++ b/seed/cli/allof-inline/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/allof-inline/src/openapi/app.rs b/seed/cli/allof-inline/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/allof-inline/src/openapi/app.rs +++ b/seed/cli/allof-inline/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/allof-inline/src/openapi/commands.rs b/seed/cli/allof-inline/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/allof-inline/src/openapi/commands.rs +++ b/seed/cli/allof-inline/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/allof-inline/src/openapi/discovery.rs b/seed/cli/allof-inline/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/allof-inline/src/openapi/discovery.rs +++ b/seed/cli/allof-inline/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/allof-inline/src/openapi/parser.rs b/seed/cli/allof-inline/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/allof-inline/src/openapi/parser.rs +++ b/seed/cli/allof-inline/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/allof-inline/src/openapi/skill_emitter.rs b/seed/cli/allof-inline/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/allof-inline/src/openapi/skill_emitter.rs +++ b/seed/cli/allof-inline/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/allof-inline/src/text.rs b/seed/cli/allof-inline/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/allof-inline/src/text.rs +++ b/seed/cli/allof-inline/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/allof/src/app.rs b/seed/cli/allof/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/allof/src/app.rs +++ b/seed/cli/allof/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/allof/src/openapi/app.rs b/seed/cli/allof/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/allof/src/openapi/app.rs +++ b/seed/cli/allof/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/allof/src/openapi/commands.rs b/seed/cli/allof/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/allof/src/openapi/commands.rs +++ b/seed/cli/allof/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/allof/src/openapi/discovery.rs b/seed/cli/allof/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/allof/src/openapi/discovery.rs +++ b/seed/cli/allof/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/allof/src/openapi/parser.rs b/seed/cli/allof/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/allof/src/openapi/parser.rs +++ b/seed/cli/allof/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/allof/src/openapi/skill_emitter.rs b/seed/cli/allof/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/allof/src/openapi/skill_emitter.rs +++ b/seed/cli/allof/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/allof/src/text.rs b/seed/cli/allof/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/allof/src/text.rs +++ b/seed/cli/allof/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/api-wide-base-path-with-default/src/app.rs b/seed/cli/api-wide-base-path-with-default/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/api-wide-base-path-with-default/src/app.rs +++ b/seed/cli/api-wide-base-path-with-default/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/api-wide-base-path-with-default/src/text.rs b/seed/cli/api-wide-base-path-with-default/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/api-wide-base-path-with-default/src/text.rs +++ b/seed/cli/api-wide-base-path-with-default/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/app.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/app.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/app.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/app.rs index e60b9503ea24..c6b94e8f9a60 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/app.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -1754,7 +1824,10 @@ impl CliApp { Some(format!("Global parameters:\n{}", rows.join("\n"))) }; let env_footer = super::commands::after_help_footer(&doc.name); + // `build_cli` already sets the env-var footer, so appending it + // unconditionally renders the block twice on the root `--help`. let base_footer = match existing_after_help { + Some(ref s) if s.contains(&env_footer) => s.clone(), Some(ref s) if !s.is_empty() => format!("{s}\n{env_footer}"), _ => env_footer, }; @@ -3423,6 +3496,25 @@ mod tests { ); } + /// `build_cli` sets the env-var footer and `decorate_command` used to + /// append it again, so the root `--help` printed the section twice. + #[test] + fn test_root_help_renders_env_footer_once() { + let doc = RestDescription { + name: "channel3".into(), + ..Default::default() + }; + let cli = crate::openapi::commands::build_cli(&doc); + let cli = CliApp::new("channel3").decorate_command(&doc, cli); + + let after_help = cli.get_after_help().expect("footer").to_string(); + assert_eq!( + after_help.matches("Environment variables:").count(), + 1, + "env-var footer should appear once, got:\n{after_help}", + ); + } + #[test] fn test_app_context_spec_accessor() { let doc = RestDescription { @@ -4299,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4315,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4330,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/commands.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/commands.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/discovery.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/discovery.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/parser.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/parser.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/skill_emitter.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-basic-auth/with-split-type-crates/src/text.rs b/seed/cli/cli-basic-auth/with-split-type-crates/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-basic-auth/with-split-type-crates/src/text.rs +++ b/seed/cli/cli-basic-auth/with-split-type-crates/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/app.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/app.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/app.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/commands.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/discovery.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-basic-auth/with-wire-tests/src/text.rs b/seed/cli/cli-basic-auth/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-basic-auth/with-wire-tests/src/text.rs +++ b/seed/cli/cli-basic-auth/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/app.rs b/seed/cli/cli-header-auth/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/app.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/app.rs b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/commands.rs b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/discovery.rs b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-header-auth/with-wire-tests/src/text.rs b/seed/cli/cli-header-auth/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-header-auth/with-wire-tests/src/text.rs +++ b/seed/cli/cli-header-auth/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/text.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/text.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/app.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/app.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/app.rs index e60b9503ea24..c6b94e8f9a60 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -1754,7 +1824,10 @@ impl CliApp { Some(format!("Global parameters:\n{}", rows.join("\n"))) }; let env_footer = super::commands::after_help_footer(&doc.name); + // `build_cli` already sets the env-var footer, so appending it + // unconditionally renders the block twice on the root `--help`. let base_footer = match existing_after_help { + Some(ref s) if s.contains(&env_footer) => s.clone(), Some(ref s) if !s.is_empty() => format!("{s}\n{env_footer}"), _ => env_footer, }; @@ -3423,6 +3496,25 @@ mod tests { ); } + /// `build_cli` sets the env-var footer and `decorate_command` used to + /// append it again, so the root `--help` printed the section twice. + #[test] + fn test_root_help_renders_env_footer_once() { + let doc = RestDescription { + name: "channel3".into(), + ..Default::default() + }; + let cli = crate::openapi::commands::build_cli(&doc); + let cli = CliApp::new("channel3").decorate_command(&doc, cli); + + let after_help = cli.get_after_help().expect("footer").to_string(); + assert_eq!( + after_help.matches("Environment variables:").count(), + 1, + "env-var footer should appear once, got:\n{after_help}", + ); + } + #[test] fn test_app_context_spec_accessor() { let doc = RestDescription { @@ -4299,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4315,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4330,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/commands.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/commands.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/discovery.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/discovery.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/parser.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/skill_emitter.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/text.rs b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/text.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-split-type-crates/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/app.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/app.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/commands.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/discovery.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/text.rs b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/text.rs +++ b/seed/cli/cli-multi-spec-namespaced/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/app.rs b/seed/cli/cli-multi-spec/no-custom-config/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/app.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/text.rs b/seed/cli/cli-multi-spec/no-custom-config/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/text.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/app.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/app.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/app.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/commands.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/discovery.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-namespace-stutter/with-wire-tests/src/text.rs b/seed/cli/cli-namespace-stutter/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-namespace-stutter/with-wire-tests/src/text.rs +++ b/seed/cli/cli-namespace-stutter/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/app.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/app.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/app.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/commands.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/discovery.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/text.rs b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-oauth-login-flow/with-wire-tests/src/text.rs +++ b/seed/cli/cli-oauth-login-flow/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-oauth/client-credentials/src/app.rs b/seed/cli/cli-oauth/client-credentials/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-oauth/client-credentials/src/app.rs +++ b/seed/cli/cli-oauth/client-credentials/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-oauth/client-credentials/src/openapi/app.rs b/seed/cli/cli-oauth/client-credentials/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-oauth/client-credentials/src/openapi/app.rs +++ b/seed/cli/cli-oauth/client-credentials/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-oauth/client-credentials/src/openapi/commands.rs b/seed/cli/cli-oauth/client-credentials/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-oauth/client-credentials/src/openapi/commands.rs +++ b/seed/cli/cli-oauth/client-credentials/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-oauth/client-credentials/src/openapi/discovery.rs b/seed/cli/cli-oauth/client-credentials/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-oauth/client-credentials/src/openapi/discovery.rs +++ b/seed/cli/cli-oauth/client-credentials/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs b/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs +++ b/seed/cli/cli-oauth/client-credentials/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-oauth/client-credentials/src/openapi/skill_emitter.rs b/seed/cli/cli-oauth/client-credentials/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-oauth/client-credentials/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-oauth/client-credentials/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-oauth/client-credentials/src/text.rs b/seed/cli/cli-oauth/client-credentials/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-oauth/client-credentials/src/text.rs +++ b/seed/cli/cli-oauth/client-credentials/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/app.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/app.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/app.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/commands.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/discovery.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-reserved-keywords/with-wire-tests/src/text.rs b/seed/cli/cli-reserved-keywords/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-reserved-keywords/with-wire-tests/src/text.rs +++ b/seed/cli/cli-reserved-keywords/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/app.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/app.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/app.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/app.rs index e60b9503ea24..c6b94e8f9a60 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/app.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -1754,7 +1824,10 @@ impl CliApp { Some(format!("Global parameters:\n{}", rows.join("\n"))) }; let env_footer = super::commands::after_help_footer(&doc.name); + // `build_cli` already sets the env-var footer, so appending it + // unconditionally renders the block twice on the root `--help`. let base_footer = match existing_after_help { + Some(ref s) if s.contains(&env_footer) => s.clone(), Some(ref s) if !s.is_empty() => format!("{s}\n{env_footer}"), _ => env_footer, }; @@ -3423,6 +3496,25 @@ mod tests { ); } + /// `build_cli` sets the env-var footer and `decorate_command` used to + /// append it again, so the root `--help` printed the section twice. + #[test] + fn test_root_help_renders_env_footer_once() { + let doc = RestDescription { + name: "channel3".into(), + ..Default::default() + }; + let cli = crate::openapi::commands::build_cli(&doc); + let cli = CliApp::new("channel3").decorate_command(&doc, cli); + + let after_help = cli.get_after_help().expect("footer").to_string(); + assert_eq!( + after_help.matches("Environment variables:").count(), + 1, + "env-var footer should appear once, got:\n{after_help}", + ); + } + #[test] fn test_app_context_spec_accessor() { let doc = RestDescription { @@ -4299,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4315,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4330,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/commands.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/commands.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/discovery.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/discovery.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/parser.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/parser.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/skill_emitter.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/skill_emitter.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/cli-shared-types/with-split-type-crates/src/text.rs b/seed/cli/cli-shared-types/with-split-type-crates/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/cli-shared-types/with-split-type-crates/src/text.rs +++ b/seed/cli/cli-shared-types/with-split-type-crates/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/app.rs b/seed/cli/discriminated-union-with-nested-oneof/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/app.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/app.rs b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/app.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/commands.rs b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/commands.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/discovery.rs b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/discovery.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/skill_emitter.rs b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/openapi/skill_emitter.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/discriminated-union-with-nested-oneof/src/text.rs b/seed/cli/discriminated-union-with-nested-oneof/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/discriminated-union-with-nested-oneof/src/text.rs +++ b/seed/cli/discriminated-union-with-nested-oneof/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/app.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/app.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/app.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/commands.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/discovery.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/parser.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/file-upload-openapi/with-wire-tests/src/text.rs b/seed/cli/file-upload-openapi/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/file-upload-openapi/with-wire-tests/src/text.rs +++ b/seed/cli/file-upload-openapi/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/imdb/src/app.rs b/seed/cli/imdb/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/imdb/src/app.rs +++ b/seed/cli/imdb/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/imdb/src/openapi/app.rs b/seed/cli/imdb/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/imdb/src/openapi/app.rs +++ b/seed/cli/imdb/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/imdb/src/openapi/commands.rs b/seed/cli/imdb/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/imdb/src/openapi/commands.rs +++ b/seed/cli/imdb/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/imdb/src/openapi/discovery.rs b/seed/cli/imdb/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/imdb/src/openapi/discovery.rs +++ b/seed/cli/imdb/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/imdb/src/openapi/parser.rs b/seed/cli/imdb/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/imdb/src/openapi/parser.rs +++ b/seed/cli/imdb/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/imdb/src/openapi/skill_emitter.rs b/seed/cli/imdb/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/imdb/src/openapi/skill_emitter.rs +++ b/seed/cli/imdb/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/imdb/src/text.rs b/seed/cli/imdb/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/imdb/src/text.rs +++ b/seed/cli/imdb/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/inline-enum-type-name-override/src/app.rs b/seed/cli/inline-enum-type-name-override/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/inline-enum-type-name-override/src/app.rs +++ b/seed/cli/inline-enum-type-name-override/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/inline-enum-type-name-override/src/openapi/app.rs b/seed/cli/inline-enum-type-name-override/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/inline-enum-type-name-override/src/openapi/app.rs +++ b/seed/cli/inline-enum-type-name-override/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/inline-enum-type-name-override/src/openapi/commands.rs b/seed/cli/inline-enum-type-name-override/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/inline-enum-type-name-override/src/openapi/commands.rs +++ b/seed/cli/inline-enum-type-name-override/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/inline-enum-type-name-override/src/openapi/discovery.rs b/seed/cli/inline-enum-type-name-override/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/inline-enum-type-name-override/src/openapi/discovery.rs +++ b/seed/cli/inline-enum-type-name-override/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs b/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs +++ b/seed/cli/inline-enum-type-name-override/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/inline-enum-type-name-override/src/openapi/skill_emitter.rs b/seed/cli/inline-enum-type-name-override/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/inline-enum-type-name-override/src/openapi/skill_emitter.rs +++ b/seed/cli/inline-enum-type-name-override/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/inline-enum-type-name-override/src/text.rs b/seed/cli/inline-enum-type-name-override/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/inline-enum-type-name-override/src/text.rs +++ b/seed/cli/inline-enum-type-name-override/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/multi-content-type-examples/src/app.rs b/seed/cli/multi-content-type-examples/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/multi-content-type-examples/src/app.rs +++ b/seed/cli/multi-content-type-examples/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/multi-content-type-examples/src/openapi/app.rs b/seed/cli/multi-content-type-examples/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/multi-content-type-examples/src/openapi/app.rs +++ b/seed/cli/multi-content-type-examples/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/multi-content-type-examples/src/openapi/commands.rs b/seed/cli/multi-content-type-examples/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/multi-content-type-examples/src/openapi/commands.rs +++ b/seed/cli/multi-content-type-examples/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/multi-content-type-examples/src/openapi/discovery.rs b/seed/cli/multi-content-type-examples/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/multi-content-type-examples/src/openapi/discovery.rs +++ b/seed/cli/multi-content-type-examples/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/multi-content-type-examples/src/openapi/parser.rs b/seed/cli/multi-content-type-examples/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/multi-content-type-examples/src/openapi/parser.rs +++ b/seed/cli/multi-content-type-examples/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/multi-content-type-examples/src/openapi/skill_emitter.rs b/seed/cli/multi-content-type-examples/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/multi-content-type-examples/src/openapi/skill_emitter.rs +++ b/seed/cli/multi-content-type-examples/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/multi-content-type-examples/src/text.rs b/seed/cli/multi-content-type-examples/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/multi-content-type-examples/src/text.rs +++ b/seed/cli/multi-content-type-examples/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/multi-url-environment-reference/src/app.rs b/seed/cli/multi-url-environment-reference/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/multi-url-environment-reference/src/app.rs +++ b/seed/cli/multi-url-environment-reference/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/multi-url-environment-reference/src/openapi/app.rs b/seed/cli/multi-url-environment-reference/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/app.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/multi-url-environment-reference/src/openapi/commands.rs b/seed/cli/multi-url-environment-reference/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/commands.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs b/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/multi-url-environment-reference/src/openapi/parser.rs b/seed/cli/multi-url-environment-reference/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/parser.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs b/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/multi-url-environment-reference/src/text.rs b/seed/cli/multi-url-environment-reference/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/multi-url-environment-reference/src/text.rs +++ b/seed/cli/multi-url-environment-reference/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/no-content-response/src/app.rs b/seed/cli/no-content-response/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/no-content-response/src/app.rs +++ b/seed/cli/no-content-response/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/no-content-response/src/openapi/app.rs b/seed/cli/no-content-response/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/no-content-response/src/openapi/app.rs +++ b/seed/cli/no-content-response/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/no-content-response/src/openapi/commands.rs b/seed/cli/no-content-response/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/no-content-response/src/openapi/commands.rs +++ b/seed/cli/no-content-response/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/no-content-response/src/openapi/discovery.rs b/seed/cli/no-content-response/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/no-content-response/src/openapi/discovery.rs +++ b/seed/cli/no-content-response/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/no-content-response/src/openapi/parser.rs b/seed/cli/no-content-response/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/no-content-response/src/openapi/parser.rs +++ b/seed/cli/no-content-response/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/no-content-response/src/openapi/skill_emitter.rs b/seed/cli/no-content-response/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/no-content-response/src/openapi/skill_emitter.rs +++ b/seed/cli/no-content-response/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/no-content-response/src/text.rs b/seed/cli/no-content-response/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/no-content-response/src/text.rs +++ b/seed/cli/no-content-response/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/null-type/src/app.rs b/seed/cli/null-type/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/null-type/src/app.rs +++ b/seed/cli/null-type/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/null-type/src/openapi/app.rs b/seed/cli/null-type/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/null-type/src/openapi/app.rs +++ b/seed/cli/null-type/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/null-type/src/openapi/commands.rs b/seed/cli/null-type/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/null-type/src/openapi/commands.rs +++ b/seed/cli/null-type/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/null-type/src/openapi/discovery.rs b/seed/cli/null-type/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/null-type/src/openapi/discovery.rs +++ b/seed/cli/null-type/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/null-type/src/openapi/parser.rs b/seed/cli/null-type/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/null-type/src/openapi/parser.rs +++ b/seed/cli/null-type/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/null-type/src/openapi/skill_emitter.rs b/seed/cli/null-type/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/null-type/src/openapi/skill_emitter.rs +++ b/seed/cli/null-type/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/null-type/src/text.rs b/seed/cli/null-type/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/null-type/src/text.rs +++ b/seed/cli/null-type/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/nullable-allof-extends/src/app.rs b/seed/cli/nullable-allof-extends/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/nullable-allof-extends/src/app.rs +++ b/seed/cli/nullable-allof-extends/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/nullable-allof-extends/src/openapi/app.rs b/seed/cli/nullable-allof-extends/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/app.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/nullable-allof-extends/src/openapi/commands.rs b/seed/cli/nullable-allof-extends/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/commands.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/nullable-allof-extends/src/openapi/discovery.rs b/seed/cli/nullable-allof-extends/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/discovery.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/nullable-allof-extends/src/openapi/parser.rs b/seed/cli/nullable-allof-extends/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/parser.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs b/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/nullable-allof-extends/src/text.rs b/seed/cli/nullable-allof-extends/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/nullable-allof-extends/src/text.rs +++ b/seed/cli/nullable-allof-extends/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/nullable-request-body/src/app.rs b/seed/cli/nullable-request-body/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/nullable-request-body/src/app.rs +++ b/seed/cli/nullable-request-body/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/nullable-request-body/src/openapi/app.rs b/seed/cli/nullable-request-body/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/nullable-request-body/src/openapi/app.rs +++ b/seed/cli/nullable-request-body/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/nullable-request-body/src/openapi/commands.rs b/seed/cli/nullable-request-body/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/nullable-request-body/src/openapi/commands.rs +++ b/seed/cli/nullable-request-body/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/nullable-request-body/src/openapi/discovery.rs b/seed/cli/nullable-request-body/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/nullable-request-body/src/openapi/discovery.rs +++ b/seed/cli/nullable-request-body/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/nullable-request-body/src/openapi/parser.rs b/seed/cli/nullable-request-body/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/nullable-request-body/src/openapi/parser.rs +++ b/seed/cli/nullable-request-body/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs b/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs +++ b/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/nullable-request-body/src/text.rs b/seed/cli/nullable-request-body/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/nullable-request-body/src/text.rs +++ b/seed/cli/nullable-request-body/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/app.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/app.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/app.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/commands.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/discovery.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/text.rs b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/text.rs +++ b/seed/cli/oauth-client-credentials-openapi/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/app.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/app.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/app.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/commands.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/discovery.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/parser.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/text.rs b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/text.rs +++ b/seed/cli/openapi-path-param-body-collision/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/openapi-request-body-ref/src/app.rs b/seed/cli/openapi-request-body-ref/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/openapi-request-body-ref/src/app.rs +++ b/seed/cli/openapi-request-body-ref/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/openapi-request-body-ref/src/openapi/app.rs b/seed/cli/openapi-request-body-ref/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/app.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/openapi-request-body-ref/src/openapi/commands.rs b/seed/cli/openapi-request-body-ref/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/commands.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs b/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/openapi-request-body-ref/src/openapi/parser.rs b/seed/cli/openapi-request-body-ref/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/parser.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs b/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/openapi-request-body-ref/src/text.rs b/seed/cli/openapi-request-body-ref/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/openapi-request-body-ref/src/text.rs +++ b/seed/cli/openapi-request-body-ref/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/openapi-subtitle/src/app.rs b/seed/cli/openapi-subtitle/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/openapi-subtitle/src/app.rs +++ b/seed/cli/openapi-subtitle/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/openapi-subtitle/src/openapi/app.rs b/seed/cli/openapi-subtitle/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/openapi-subtitle/src/openapi/app.rs +++ b/seed/cli/openapi-subtitle/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/openapi-subtitle/src/openapi/commands.rs b/seed/cli/openapi-subtitle/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/openapi-subtitle/src/openapi/commands.rs +++ b/seed/cli/openapi-subtitle/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/openapi-subtitle/src/openapi/discovery.rs b/seed/cli/openapi-subtitle/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/openapi-subtitle/src/openapi/discovery.rs +++ b/seed/cli/openapi-subtitle/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/openapi-subtitle/src/openapi/parser.rs b/seed/cli/openapi-subtitle/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/openapi-subtitle/src/openapi/parser.rs +++ b/seed/cli/openapi-subtitle/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/openapi-subtitle/src/openapi/skill_emitter.rs b/seed/cli/openapi-subtitle/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/openapi-subtitle/src/openapi/skill_emitter.rs +++ b/seed/cli/openapi-subtitle/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/openapi-subtitle/src/text.rs b/seed/cli/openapi-subtitle/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/openapi-subtitle/src/text.rs +++ b/seed/cli/openapi-subtitle/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-param-name-conflict/src/app.rs b/seed/cli/query-param-name-conflict/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-param-name-conflict/src/app.rs +++ b/seed/cli/query-param-name-conflict/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-param-name-conflict/src/openapi/app.rs b/seed/cli/query-param-name-conflict/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/app.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-param-name-conflict/src/openapi/commands.rs b/seed/cli/query-param-name-conflict/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/commands.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-param-name-conflict/src/openapi/discovery.rs b/seed/cli/query-param-name-conflict/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/discovery.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-param-name-conflict/src/openapi/parser.rs b/seed/cli/query-param-name-conflict/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/parser.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs b/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-param-name-conflict/src/text.rs b/seed/cli/query-param-name-conflict/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-param-name-conflict/src/text.rs +++ b/seed/cli/query-param-name-conflict/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi-as-objects/src/app.rs b/seed/cli/query-parameters-openapi-as-objects/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/app.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi-as-objects/src/text.rs b/seed/cli/query-parameters-openapi-as-objects/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/text.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/app.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/app.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/app.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/github-distribution-app/src/text.rs b/seed/cli/query-parameters-openapi/github-distribution-app/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/github-distribution-app/src/text.rs +++ b/seed/cli/query-parameters-openapi/github-distribution-app/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/app.rs b/seed/cli/query-parameters-openapi/github-distribution/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/app.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/app.rs b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/github-distribution/src/text.rs b/seed/cli/query-parameters-openapi/github-distribution/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/github-distribution/src/text.rs +++ b/seed/cli/query-parameters-openapi/github-distribution/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/app.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/app.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/app.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/github-no-publish/src/text.rs b/seed/cli/query-parameters-openapi/github-no-publish/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/github-no-publish/src/text.rs +++ b/seed/cli/query-parameters-openapi/github-no-publish/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/github-npm/src/app.rs b/seed/cli/query-parameters-openapi/github-npm/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/app.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/github-npm/src/text.rs b/seed/cli/query-parameters-openapi/github-npm/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/github-npm/src/text.rs +++ b/seed/cli/query-parameters-openapi/github-npm/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/text.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/text.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/app.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/app.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/app.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/app.rs index e60b9503ea24..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -1754,7 +1824,10 @@ impl CliApp { Some(format!("Global parameters:\n{}", rows.join("\n"))) }; let env_footer = super::commands::after_help_footer(&doc.name); + // `build_cli` already sets the env-var footer, so appending it + // unconditionally renders the block twice on the root `--help`. let base_footer = match existing_after_help { + Some(ref s) if s.contains(&env_footer) => s.clone(), Some(ref s) if !s.is_empty() => format!("{s}\n{env_footer}"), _ => env_footer, }; @@ -3423,6 +3496,25 @@ mod tests { ); } + /// `build_cli` sets the env-var footer and `decorate_command` used to + /// append it again, so the root `--help` printed the section twice. + #[test] + fn test_root_help_renders_env_footer_once() { + let doc = RestDescription { + name: "channel3".into(), + ..Default::default() + }; + let cli = crate::openapi::commands::build_cli(&doc); + let cli = CliApp::new("channel3").decorate_command(&doc, cli); + + let after_help = cli.get_after_help().expect("footer").to_string(); + assert_eq!( + after_help.matches("Environment variables:").count(), + 1, + "env-var footer should appear once, got:\n{after_help}", + ); + } + #[test] fn test_app_context_spec_accessor() { let doc = RestDescription { @@ -4299,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4315,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4330,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/with-split-type-crates/src/text.rs b/seed/cli/query-parameters-openapi/with-split-type-crates/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/with-split-type-crates/src/text.rs +++ b/seed/cli/query-parameters-openapi/with-split-type-crates/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/app.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/app.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/app.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/skill_emitter.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/query-parameters-openapi/with-wire-tests/src/text.rs b/seed/cli/query-parameters-openapi/with-wire-tests/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/query-parameters-openapi/with-wire-tests/src/text.rs +++ b/seed/cli/query-parameters-openapi/with-wire-tests/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/respect-optional-request-body/src/app.rs b/seed/cli/respect-optional-request-body/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/respect-optional-request-body/src/app.rs +++ b/seed/cli/respect-optional-request-body/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/respect-optional-request-body/src/openapi/app.rs b/seed/cli/respect-optional-request-body/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/respect-optional-request-body/src/openapi/app.rs +++ b/seed/cli/respect-optional-request-body/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/respect-optional-request-body/src/openapi/commands.rs b/seed/cli/respect-optional-request-body/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/respect-optional-request-body/src/openapi/commands.rs +++ b/seed/cli/respect-optional-request-body/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/respect-optional-request-body/src/openapi/discovery.rs b/seed/cli/respect-optional-request-body/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/respect-optional-request-body/src/openapi/discovery.rs +++ b/seed/cli/respect-optional-request-body/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/respect-optional-request-body/src/openapi/parser.rs b/seed/cli/respect-optional-request-body/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/respect-optional-request-body/src/openapi/parser.rs +++ b/seed/cli/respect-optional-request-body/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/respect-optional-request-body/src/openapi/skill_emitter.rs b/seed/cli/respect-optional-request-body/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/respect-optional-request-body/src/openapi/skill_emitter.rs +++ b/seed/cli/respect-optional-request-body/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/respect-optional-request-body/src/text.rs b/seed/cli/respect-optional-request-body/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/respect-optional-request-body/src/text.rs +++ b/seed/cli/respect-optional-request-body/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/schemaless-request-body-examples/src/app.rs b/seed/cli/schemaless-request-body-examples/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/schemaless-request-body-examples/src/app.rs +++ b/seed/cli/schemaless-request-body-examples/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/app.rs b/seed/cli/schemaless-request-body-examples/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/app.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs b/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs b/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs b/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs b/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/schemaless-request-body-examples/src/text.rs b/seed/cli/schemaless-request-body-examples/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/schemaless-request-body-examples/src/text.rs +++ b/seed/cli/schemaless-request-body-examples/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/server-sent-events-openapi/src/app.rs b/seed/cli/server-sent-events-openapi/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/server-sent-events-openapi/src/app.rs +++ b/seed/cli/server-sent-events-openapi/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/server-sent-events-openapi/src/openapi/app.rs b/seed/cli/server-sent-events-openapi/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/app.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/server-sent-events-openapi/src/openapi/commands.rs b/seed/cli/server-sent-events-openapi/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/commands.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs b/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/server-sent-events-openapi/src/openapi/parser.rs b/seed/cli/server-sent-events-openapi/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/parser.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs b/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/server-sent-events-openapi/src/text.rs b/seed/cli/server-sent-events-openapi/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/server-sent-events-openapi/src/text.rs +++ b/seed/cli/server-sent-events-openapi/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/server-url-templating-single-url/src/app.rs b/seed/cli/server-url-templating-single-url/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/server-url-templating-single-url/src/app.rs +++ b/seed/cli/server-url-templating-single-url/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/server-url-templating-single-url/src/openapi/app.rs b/seed/cli/server-url-templating-single-url/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/server-url-templating-single-url/src/openapi/app.rs +++ b/seed/cli/server-url-templating-single-url/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/server-url-templating-single-url/src/openapi/commands.rs b/seed/cli/server-url-templating-single-url/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/server-url-templating-single-url/src/openapi/commands.rs +++ b/seed/cli/server-url-templating-single-url/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/server-url-templating-single-url/src/openapi/discovery.rs b/seed/cli/server-url-templating-single-url/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/server-url-templating-single-url/src/openapi/discovery.rs +++ b/seed/cli/server-url-templating-single-url/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/server-url-templating-single-url/src/openapi/parser.rs b/seed/cli/server-url-templating-single-url/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/server-url-templating-single-url/src/openapi/parser.rs +++ b/seed/cli/server-url-templating-single-url/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/server-url-templating-single-url/src/openapi/skill_emitter.rs b/seed/cli/server-url-templating-single-url/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/server-url-templating-single-url/src/openapi/skill_emitter.rs +++ b/seed/cli/server-url-templating-single-url/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/server-url-templating-single-url/src/text.rs b/seed/cli/server-url-templating-single-url/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/server-url-templating-single-url/src/text.rs +++ b/seed/cli/server-url-templating-single-url/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/server-url-templating/src/app.rs b/seed/cli/server-url-templating/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/server-url-templating/src/app.rs +++ b/seed/cli/server-url-templating/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/server-url-templating/src/openapi/app.rs b/seed/cli/server-url-templating/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/server-url-templating/src/openapi/app.rs +++ b/seed/cli/server-url-templating/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/server-url-templating/src/openapi/commands.rs b/seed/cli/server-url-templating/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/server-url-templating/src/openapi/commands.rs +++ b/seed/cli/server-url-templating/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/server-url-templating/src/openapi/discovery.rs b/seed/cli/server-url-templating/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/server-url-templating/src/openapi/discovery.rs +++ b/seed/cli/server-url-templating/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/server-url-templating/src/openapi/parser.rs b/seed/cli/server-url-templating/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/server-url-templating/src/openapi/parser.rs +++ b/seed/cli/server-url-templating/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/server-url-templating/src/openapi/skill_emitter.rs b/seed/cli/server-url-templating/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/server-url-templating/src/openapi/skill_emitter.rs +++ b/seed/cli/server-url-templating/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/server-url-templating/src/text.rs b/seed/cli/server-url-templating/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/server-url-templating/src/text.rs +++ b/seed/cli/server-url-templating/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/url-form-encoded/src/app.rs b/seed/cli/url-form-encoded/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/url-form-encoded/src/app.rs +++ b/seed/cli/url-form-encoded/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/url-form-encoded/src/openapi/app.rs b/seed/cli/url-form-encoded/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/url-form-encoded/src/openapi/app.rs +++ b/seed/cli/url-form-encoded/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/url-form-encoded/src/openapi/commands.rs b/seed/cli/url-form-encoded/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/url-form-encoded/src/openapi/commands.rs +++ b/seed/cli/url-form-encoded/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/url-form-encoded/src/openapi/discovery.rs b/seed/cli/url-form-encoded/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/url-form-encoded/src/openapi/discovery.rs +++ b/seed/cli/url-form-encoded/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/url-form-encoded/src/openapi/parser.rs b/seed/cli/url-form-encoded/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/url-form-encoded/src/openapi/parser.rs +++ b/seed/cli/url-form-encoded/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs b/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs +++ b/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/url-form-encoded/src/text.rs b/seed/cli/url-form-encoded/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/url-form-encoded/src/text.rs +++ b/seed/cli/url-form-encoded/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/webhook-audience/src/app.rs b/seed/cli/webhook-audience/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/webhook-audience/src/app.rs +++ b/seed/cli/webhook-audience/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/webhook-audience/src/openapi/app.rs b/seed/cli/webhook-audience/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/webhook-audience/src/openapi/app.rs +++ b/seed/cli/webhook-audience/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/webhook-audience/src/openapi/commands.rs b/seed/cli/webhook-audience/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/webhook-audience/src/openapi/commands.rs +++ b/seed/cli/webhook-audience/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/webhook-audience/src/openapi/discovery.rs b/seed/cli/webhook-audience/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/webhook-audience/src/openapi/discovery.rs +++ b/seed/cli/webhook-audience/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/webhook-audience/src/openapi/parser.rs b/seed/cli/webhook-audience/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/webhook-audience/src/openapi/parser.rs +++ b/seed/cli/webhook-audience/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/webhook-audience/src/openapi/skill_emitter.rs b/seed/cli/webhook-audience/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/webhook-audience/src/openapi/skill_emitter.rs +++ b/seed/cli/webhook-audience/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/webhook-audience/src/text.rs b/seed/cli/webhook-audience/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/webhook-audience/src/text.rs +++ b/seed/cli/webhook-audience/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/x-fern-default/src/app.rs b/seed/cli/x-fern-default/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/x-fern-default/src/app.rs +++ b/seed/cli/x-fern-default/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/x-fern-default/src/openapi/app.rs b/seed/cli/x-fern-default/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/x-fern-default/src/openapi/app.rs +++ b/seed/cli/x-fern-default/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/x-fern-default/src/openapi/commands.rs b/seed/cli/x-fern-default/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/x-fern-default/src/openapi/commands.rs +++ b/seed/cli/x-fern-default/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/x-fern-default/src/openapi/discovery.rs b/seed/cli/x-fern-default/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/x-fern-default/src/openapi/discovery.rs +++ b/seed/cli/x-fern-default/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/x-fern-default/src/openapi/parser.rs b/seed/cli/x-fern-default/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/x-fern-default/src/openapi/parser.rs +++ b/seed/cli/x-fern-default/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/x-fern-default/src/openapi/skill_emitter.rs b/seed/cli/x-fern-default/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/x-fern-default/src/openapi/skill_emitter.rs +++ b/seed/cli/x-fern-default/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/x-fern-default/src/text.rs b/seed/cli/x-fern-default/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/x-fern-default/src/text.rs +++ b/seed/cli/x-fern-default/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/app.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/app.rs index f7df330b3ffc..adcc33729b6a 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/app.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/app.rs @@ -1269,8 +1269,22 @@ fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Co // are intercepted pre-clap anyway, so the built-in wins. return cli.mut_subcommand(name, move |_spec_owned| builtin); } + let builtin_about = builtin.get_about().map(ToString::to_string); + let builtin_long_about = builtin.get_long_about().map(ToString::to_string); + let generic_about = crate::openapi::commands::generic_group_about(&name); cli.mut_subcommand(name, move |spec_owned| { let mut merged = spec_owned; + let spec_uses_generic_about = merged + .get_about() + .is_none_or(|about| about.to_string() == generic_about); + if spec_uses_generic_about { + if let Some(about) = builtin_about { + merged = merged.about(about); + } + if let Some(long_about) = builtin_long_about { + merged = merged.long_about(long_about); + } + } for sub in builtin_subs { merged = crate::custom_commands::graft_subcommand(merged, &[], sub); } @@ -1770,13 +1784,49 @@ mod tests { assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); } + #[test] + fn graft_builtin_about_wins_over_generic_spec_fallback() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about(crate::openapi::commands::generic_group_about("auth")) + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("Manage credentials (login / logout / status)"), + ); + } + + #[test] + fn graft_builtin_preserves_real_spec_about() { + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .about("API authentication operations") + .subcommand(clap::Command::new("me")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + let auth = cli.find_subcommand("auth").expect("auth group survives"); + assert_eq!( + auth.get_about().map(ToString::to_string).as_deref(), + Some("API authentication operations"), + ); + } + #[test] fn graft_builtin_registers_when_no_collision() { let cli = graft_builtin_command( clap::Command::new("root").subcommand(clap::Command::new("users")), crate::auth::login::build_auth_command(), ); - assert!(cli.find_subcommand("auth").is_some()); + assert_eq!( + cli.find_subcommand("auth") + .and_then(|auth| auth.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Manage credentials (login / logout / status)"), + ); assert!(cli.find_subcommand("users").is_some()); } diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/app.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/app.rs index 416fdb2ce8d9..c6b94e8f9a60 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/app.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/app.rs @@ -233,6 +233,61 @@ fn merge_security_schemes( } } +/// Merge document-root OpenAPI tag descriptions across specs. First write +/// wins on normalized-name collisions, preserving deterministic metadata when +/// multiple specs declare the same tag. +fn merge_tag_descriptions( + acc: &mut HashMap, + incoming: HashMap, +) { + for (name, description) in incoming { + acc.entry(name).or_insert(description); + } +} + +fn merge_group_tag_names( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for tag in tags { + if !existing.iter().any(|existing_tag| existing_tag == &tag) { + existing.push(tag); + } + } + } +} + +fn merge_group_tag_operation_counts( + acc: &mut HashMap>, + incoming: HashMap>, +) { + for (group, tags) in incoming { + let existing = acc.entry(group).or_default(); + for (tag, count) in tags { + *existing.entry(tag).or_default() += count; + } + } +} + +fn merge_group_operation_counts( + acc: &mut HashMap, + incoming: HashMap, +) { + for (group, count) in incoming { + *acc.entry(group).or_default() += count; + } +} + +fn merge_tag_description_order(acc: &mut Vec, incoming: Vec) { + for tag in incoming { + if !acc.iter().any(|existing| existing == &tag) { + acc.push(tag); + } + } +} + /// Merge `x-fern-sdk-variables` declarations across specs. First write /// wins on name collisions, mirroring [`merge_schemas`] and /// [`merge_security_schemes`]. Multi-spec setups that share a common @@ -1189,6 +1244,21 @@ impl CliApp { merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; merge_schemas(&mut acc.schemas, spec_doc.schemas)?; merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_tag_descriptions(&mut acc.tag_descriptions, spec_doc.tag_descriptions); + merge_group_tag_names(&mut acc.group_tag_names, spec_doc.group_tag_names); + merge_group_tag_operation_counts( + &mut acc.group_tag_operation_counts, + spec_doc.group_tag_operation_counts, + ); + merge_group_operation_counts( + &mut acc.group_operation_counts, + spec_doc.group_operation_counts, + ); + merge_group_tag_names(&mut acc.tag_group_names, spec_doc.tag_group_names); + merge_tag_description_order( + &mut acc.tag_description_order, + spec_doc.tag_description_order, + ); merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); @@ -4321,6 +4391,9 @@ openapi: "3.0.0" info: title: "API A" version: "1.0" +tags: + - name: users + description: User operations. servers: - url: "https://api-a.example.com" paths: @@ -4337,6 +4410,9 @@ openapi: "3.0.0" info: title: "API B" version: "1.0" +tags: + - name: orders + description: Order operations. servers: - url: "https://api-b.example.com" paths: @@ -4352,6 +4428,53 @@ paths: let doc = app.build_doc().unwrap(); assert!(doc.resources.contains_key("users")); assert!(doc.resources.contains_key("orders")); + assert_eq!( + doc.tag_descriptions.get("users").map(String::as_str), + Some("User operations."), + ); + assert_eq!( + doc.tag_descriptions.get("orders").map(String::as_str), + Some("Order operations."), + ); + } + + #[test] + fn test_multi_spec_tag_descriptions_first_write_wins() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "API A", version: "1.0" } +tags: + - name: shared + description: First description. +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "API B", version: "1.0" } +tags: + - name: shared + description: Second description. +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("test") + .spec(spec_a) + .spec(spec_b) + .build_doc() + .unwrap(); + assert_eq!( + doc.tag_descriptions.get("shared").map(String::as_str), + Some("First description."), + ); } #[test] diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/commands.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/commands.rs index 16c801429b9c..4340d37f7ad0 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/commands.rs @@ -200,7 +200,18 @@ pub fn build_cli(doc: &RestDescription) -> Command { resource_names.sort(); for name in resource_names { let resource = &doc.resources[name]; - if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + if let Some(cmd) = build_resource_command( + name, + resource, + &doc.groups, + &doc.tag_descriptions, + &doc.group_tag_names, + &doc.group_tag_operation_counts, + &doc.group_operation_counts, + &doc.tag_group_names, + &doc.tag_description_order, + ) + { root = root.subcommand(cmd); } } @@ -208,32 +219,247 @@ pub fn build_cli(doc: &RestDescription) -> Command { root } -/// Resolve the `about()` line for a group's clap subcommand. Returns -/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from -/// the document-root `x-fern-groups` extension) when present; falls -/// back to the legacy `Operations on ''` label otherwise. The -/// fallback preserves the current default behavior unchanged for any -/// group identifier that doesn't appear in `x-fern-groups`. -pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { - groups +pub(crate) fn group_tag_description_for_group( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option { + find_tag_description( + name, + group_tags, + tag_descriptions, + group_tag_operation_counts, + group_operation_count, + tag_group_names, + tag_description_order, + ) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn find_tag_description<'a>( + name: &str, + group_tags: Option<&[String]>, + tag_descriptions: &'a HashMap, + group_tag_operation_counts: Option<&HashMap>, + group_operation_count: Option, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> Option<&'a String> { + if let Some(group_tags) = group_tags { + let name_key = tag_match_key(name); + for declared_tag in tag_description_order { + let declared_tag_key = tag_match_key(declared_tag); + if declared_tag_key == name_key + && group_tags + .iter() + .any(|tag| tag_match_key(tag) == declared_tag_key) + { + if let Some(description) = tag_descriptions.get(declared_tag) { + return Some(description); + } + } + } + // A tag named after the group is the group's own identity. When the + // group's operations declare it, it is the only tag allowed to + // describe the group: the loop above already returned if it carries a + // description, so reaching here means it documents nothing and the + // group has no description of its own. Borrowing a sibling tag's + // prose there mislabels the group — an ElevenLabs `voices` group whose + // ops carry both `voices` (no description) and `pvc-voices` would be + // announced as "Create and manage Professional Voice Clones (PVCs)", + // naming a subset of what the group actually does. + let group_declares_own_name_tag = group_tags + .iter() + .any(|tag| tag_match_key(tag) == name_key); + if group_declares_own_name_tag { + return None; + } + + let operation_count = group_operation_count.unwrap_or(0); + // The group's most-declared tag, counting tags that carry no root + // description. A tag the group's own operations declare *less* often + // than another describes a subset of the group, not the group — e.g. + // a `voices` group whose ops split 19 `voices` / 14 `pvc-voices` + // would otherwise be labelled "Professional Voice Clones (PVCs)" + // purely because the dominant tag happens to document nothing. + let dominant_tag_operation_count = group_tag_operation_counts + .and_then(|counts| counts.values().copied().max()) + .unwrap_or(0); + if operation_count > 0 { + let mut candidates = group_tags + .iter() + .filter_map(|operation_tag| { + let tag_key = tag_match_key(operation_tag); + let declared_tag = tag_description_order + .iter() + .find(|declared| tag_match_key(declared) == tag_key)?; + if operation_tag.contains(':') + || declared_tag.contains(':') + || !tag_group_names + .get(&tag_key) + .is_some_and(|groups| groups.len() == 1 && groups[0] == name) + { + return None; + } + let count = group_tag_operation_counts + .and_then(|counts| counts.get(&tag_key)) + .copied() + .unwrap_or(0); + if count * 2 < operation_count || count < dominant_tag_operation_count { + return None; + } + let declaration_index = tag_description_order + .iter() + .position(|declared| declared == declared_tag)?; + Some((count, declaration_index, declared_tag)) + }) + .collect::>(); + candidates.sort_by_key(|(count, declaration_index, _)| { + (std::cmp::Reverse(*count), *declaration_index) + }); + if let Some((_, _, declared_tag)) = candidates.first() { + if let Some(description) = tag_descriptions.get(*declared_tag) { + return Some(description); + } + } + } + } + + tag_descriptions + .get(name) + .or_else(|| { + let normalized_name = tag_match_key(name); + tag_descriptions + .iter() + .find(|(tag_name, _)| tag_match_key(tag_name) == normalized_name) + .map(|(_, description)| description) + }) +} + +/// Return the generic `about()` line for a group without usable metadata. +pub(crate) fn generic_group_about(name: &str) -> String { + format!("Operations on '{name}'") +} + +/// Resolve the `about()` line for a group's clap subcommand. The legacy +/// fallback is unchanged for groups without metadata. +fn group_about_text_for_group( + name: &str, + groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], +) -> String { + let tag_description = group_tag_description_for_group( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .filter(|description| !description.is_empty()); + + // An explicit `x-fern-groups.description` is Fern-side configuration, so + // it outranks prose inferred from the spec's tags — the same precedence + // the agent-skill emitter already applies. Only its first sentence + // reaches the command table; `long_about` keeps the full text. + let configured_description = groups + .get(name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.trim().is_empty()) + .map(|description| { + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ) + .trim() + .to_string() + }) + .filter(|description| !description.is_empty()); + + if let Some(summary) = groups .get(name) .and_then(|info| info.summary.clone()) .filter(|s| !s.is_empty()) - .unwrap_or_else(|| format!("Operations on '{name}'")) + { + if !crate::text::is_name_restating(&summary, name) + || (configured_description.is_none() && tag_description.is_none()) + { + return summary; + } + } + + configured_description + .or(tag_description) + .unwrap_or_else(|| generic_group_about(name)) } -/// Resolve the `long_about()` line for a group's clap subcommand from -/// the document-root `x-fern-groups` extension's `description` field. -/// `None` when the group has no entry or the entry omits `description` -/// — clap then falls back to the `about()` text for `--help`. -pub(crate) fn group_long_about_text( +/// Resolve the `long_about()` line for a group's clap subcommand. Fern group +/// descriptions take precedence over full OpenAPI tag descriptions. A +/// description equal to the about line is omitted to avoid redundant help. +fn group_long_about_text_for_group( name: &str, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { + let about = group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ); groups .get(name) .and_then(|info| info.description.clone()) .filter(|s| !s.is_empty()) + .or_else(|| { + find_tag_description( + name, + group_tag_names.get(name).map(Vec::as_slice), + tag_descriptions, + group_tag_operation_counts.get(name), + group_operation_counts.get(name).copied(), + tag_group_names, + tag_description_order, + ) + .cloned() + }) + .filter(|description| description.trim() != about.trim()) } /// Stringify a parameter's resolved client-side default value for @@ -290,13 +516,37 @@ fn build_resource_command( name: &str, resource: &RestResource, groups: &HashMap, + tag_descriptions: &HashMap, + group_tag_names: &HashMap>, + group_tag_operation_counts: &HashMap>, + group_operation_counts: &HashMap, + tag_group_names: &HashMap>, + tag_description_order: &[String], ) -> Option { let mut cmd = Command::new(name.to_string()) - .about(group_about_text(name, groups)) + .about(group_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + )) .subcommand_required(true) .arg_required_else_help(true); - if let Some(long_about) = group_long_about_text(name, groups) { + if let Some(long_about) = group_long_about_text_for_group( + name, + groups, + tag_descriptions, + group_tag_names, + group_tag_operation_counts, + group_operation_counts, + tag_group_names, + tag_description_order, + ) { cmd = cmd.long_about(long_about); } @@ -310,12 +560,28 @@ fn build_resource_command( has_children = true; - let about = crate::text::truncate_description( - method.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, + // `about` is the one-line entry in the parent's command table, so it + // stays a single short sentence. `long_about` is what ` + // --help` renders, where the user has asked for detail — keep the + // fuller prose there, still capped so a verbose spec can't flood the + // terminal. + let description = method.description.as_deref().unwrap_or(""); + let short_description = crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + ); + // Prefer the operation's own prose when the spec carries it apart + // from the terse summary; otherwise the long form is just the + // untruncated summary. + let long_description = crate::text::truncate_description( + method.long_description.as_deref().unwrap_or(description), + crate::text::CLI_LONG_DESCRIPTION_LIMIT, true, ); - let about = with_availability_badge(&about, method.availability); + let about = with_availability_badge(&short_description, method.availability); + let long_about = (long_description.trim() != short_description.trim()) + .then(|| with_availability_badge(&long_description, method.availability)); let mut method_cmd = Command::new(method_name.to_string()) .about(about) @@ -326,6 +592,10 @@ fn build_resource_command( .value_name("JSON"), ); + if let Some(long_about) = long_about { + method_cmd = method_cmd.long_about(long_about); + } + // `-o, --output PATH` is only meaningful for operations that can // return a binary body — the JSON path in `process_response` never // consults `output_path`, so on pure-JSON ops the flag would @@ -519,12 +789,6 @@ fn build_resource_command( Cow::Borrowed(base_value_name) }; - let help_text = crate::text::truncate_description( - param.description.as_deref().unwrap_or(""), - crate::text::CLI_DESCRIPTION_LIMIT, - true, - ); - let help_text = with_availability_badge(&help_text, param.availability); // When the CLI flag differs from the wire name — whether via // `x-fern-parameter-name` rename or sanitization — surface // the original wire name in `--help` so users can correlate @@ -533,35 +797,51 @@ fn build_resource_command( // name in their description, so they skip this. let flag_differs_from_wire = param.flag_name_override.is_none() && kebab_name != *param_name; - let help_text = if flag_differs_from_wire { - if help_text.is_empty() { - format!("(api: {param_name})") + // Both tiers carry the same annotations; only the amount of + // prose differs. The `[default: ...]` suffix mirrors the shape + // clap renders for `x-fern-default`, so the user sees "there is + // a default" without being told who applies it. The CLI itself + // does not send the documentation default on the wire. + let decorate = |text: &str| -> String { + let text = with_availability_badge(text, param.availability); + let text = if flag_differs_from_wire { + if text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{text} (api: {param_name})") + } } else { - format!("{help_text} (api: {param_name})") + text + }; + match documentation_default_help_suffix(¶m.documentation_default_value) { + Some(suffix) => format!("{text}{suffix}"), + None => text, } - } else { - help_text - }; - // Append the OpenAPI standard `default:` value as a - // `[default: ...]` suffix when it is the only default - // source. Same visual shape as clap's auto-rendered - // `[default: ...]` for `x-fern-default` — the user sees - // "there is a default" without being told whether the CLI - // or the server applies it. The CLI itself does not send - // this value on the wire (only `x-fern-default` populates - // `default_value` below). - let help_text = match documentation_default_help_suffix( - ¶m.documentation_default_value, - ) { - Some(suffix) => format!("{help_text}{suffix}"), - None => help_text, }; + let description = + crate::text::collapse_whitespace(param.description.as_deref().unwrap_or("")); + let help_text = decorate(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = decorate(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let arg_id = param_clap_arg_id(param_name); let mut arg = Arg::new(arg_id) .long(kebab_name) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + // `-h` shows the one-line form, `--help` the fuller prose. Only + // set the long form when it actually says more, so `-h` and + // `--help` do not print identical blocks. + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } // Only `x-fern-default` (lowered into `default_value`) // becomes a clap default. The standard `default:` keyword @@ -598,9 +878,21 @@ fn build_resource_command( // Add sub-resource subcommands (recursive) let mut sub_names: Vec<_> = resource.resources.keys().collect(); sub_names.sort(); + let no_nested_tag_descriptions = HashMap::new(); for sub_name in sub_names { let sub_resource = &resource.resources[sub_name]; - if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + if let Some(sub_cmd) = build_resource_command( + sub_name, + sub_resource, + groups, + &no_nested_tag_descriptions, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &[], + ) + { has_children = true; cmd = cmd.subcommand(sub_cmd); } @@ -727,17 +1019,38 @@ fn build_multipart_field_arg(field: &MultipartField) -> Arg { ("VALUE", "") }; - let help_text = match (&field.description, help_prefix) { - (Some(desc), "") => desc.clone(), - (Some(desc), prefix) => format!("{prefix}. {desc}"), - (None, prefix) if !prefix.is_empty() => prefix.to_string(), - _ => String::new(), + // Multipart field prose used to reach `--help` untruncated, while every + // other parameter went through `truncate_description`. A single upload + // operation could therefore render several hundred characters per flag. + let description = crate::text::collapse_whitespace( + field.description.as_deref().unwrap_or(""), + ); + let compose = |text: &str| -> String { + match (text.is_empty(), help_prefix) { + (false, "") => text.to_string(), + (false, prefix) => format!("{prefix}. {text}"), + (true, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + } }; + let help_text = compose(&crate::text::truncate_description( + &crate::text::first_sentence(&description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true, + )); + let long_help_text = compose(&crate::text::truncate_description( + &description, + crate::text::CLI_LONG_DESCRIPTION_LIMIT, + true, + )); let mut arg = Arg::new(field.wire_name.clone()) .long(kebab) .value_name(value_name) - .help(help_text); + .help(help_text.clone()); + if long_help_text != help_text { + arg = arg.long_help(long_help_text); + } if field.required { arg = arg.required(true); @@ -1209,6 +1522,108 @@ mod tests { assert_eq!(date_min.get_long().unwrap(), "date-created-min"); } + /// `-h` shows a one-line form of a flag's prose and `--help` the fuller + /// text. Before this the same 200-char block was printed in both, so a + /// request with 40 documented fields made `-h` unusable as a quick scan. + #[test] + fn test_flag_help_is_short_in_h_and_full_in_long_help() { + let mut params = HashMap::new(); + params.insert( + "threshold".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some( + "Diarization threshold to apply. A higher value means a lower chance of one speaker being split in two, and a higher chance of two speakers being merged into one." + .to_string(), + ), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "threshold").cloned()) + .expect("threshold arg missing"); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "Diarization threshold to apply."); + assert!( + long.len() > short.len() && long.starts_with("Diarization threshold to apply. A higher"), + "long help should carry the fuller prose; got: {long}", + ); + assert!( + long.ends_with("merged into one."), + "long help should keep the whole description; got: {long}", + ); + } + + /// A flag whose prose already fits gets no second copy, so `-h` and + /// `--help` do not print the same block twice. + #[test] + fn test_short_flag_help_has_no_redundant_long_help() { + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID.".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { methods, resources: HashMap::new() }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let arg = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "id").cloned()) + .expect("id arg missing"); + assert_eq!(arg.get_help().map(ToString::to_string).as_deref(), Some("Filter by ID.")); + assert!(arg.get_long_help().is_none()); + } + #[test] fn test_sanitized_flag_help_shows_wire_name() { let mut params = HashMap::new(); @@ -1992,13 +2407,569 @@ mod tests { assert!(things.get_long_about().is_none()); } - /// `description` populates `long_about()` so `--help` shows the - /// detailed prose for the group. Setting `description` alone - /// (without `summary`) keeps the legacy short label — fern's IR - /// allows either field to be present without the other and we - /// preserve that asymmetry. #[test] - fn test_group_description_sets_long_about_only() { + fn test_tag_description_drives_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "", + ); + } + + #[test] + fn test_tag_description_about_is_single_line_and_strips_markdown_links() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide)." + .to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage things across multiple lines.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Manage things across\nmultiple lines. See [the API guide](https://example.com/guide).", + ); + } + + #[test] + fn test_short_about_uses_first_sentence_and_preserves_long_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is the first sentence with enough words to exceed the short CLI limit. The full description remains available from the group help."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + doc.group_tag_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_description_order = vec!["things".to_string()]; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some( + "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is not shown in the table." + .to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "This is the first sentence with enough words to exceed the short CLI limit.", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + assert_eq!( + things + .find_subcommand("list") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + } + + #[test] + fn test_method_long_about_keeps_prose_the_table_line_drops() { + let mut doc = make_doc_with_things_resource(); + let description = "This method's first sentence also has enough words to exceed the short CLI limit. The remaining prose is only shown by the method's own help."; + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description.to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "This method's first sentence also has enough words to exceed the short CLI…", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + description, + ); + } + + /// A description that already fits the table line adds nothing as + /// `long_about`, so it is left unset rather than rendered twice. + #[test] + fn test_method_long_about_omitted_when_it_matches_the_table_line() { + let mut doc = make_doc_with_things_resource(); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some("Lists the things.".to_string()); + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "Lists the things.", + ); + assert!(list.get_long_about().is_none()); + } + + /// An operation whose spec carries prose apart from its terse summary + /// shows the summary in the parent's table and the prose under its own + /// `--help`. Before this, the parser kept only `summary` and the prose + /// was unreachable anywhere in the CLI. + #[test] + fn test_method_long_about_prefers_operation_prose_over_summary() { + let mut doc = make_doc_with_things_resource(); + { + let method = doc + .resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing"); + method.description = Some("List things".to_string()); + method.long_description = Some( + "Returns every thing visible to the caller, newest first. Results are paginated." + .to_string(), + ); + } + + let cmd = build_cli(&doc); + let list = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .expect("list subcommand missing"); + assert_eq!( + list.get_about().map(ToString::to_string).unwrap_or_default(), + "List things", + ); + assert_eq!( + list.get_long_about() + .map(ToString::to_string) + .unwrap_or_default(), + "Returns every thing visible to the caller, newest first. Results are paginated.", + ); + } + + /// Verbose specs must not flood the terminal: `long_about` stays capped + /// at [`crate::text::CLI_LONG_DESCRIPTION_LIMIT`]. + #[test] + fn test_method_long_about_is_capped() { + let mut doc = make_doc_with_things_resource(); + let description = "Sentence one is long enough to matter here. ".repeat(20); + doc.resources + .get_mut("things") + .expect("things resource missing") + .methods + .get_mut("list") + .expect("list method missing") + .description = Some(description); + + let cmd = build_cli(&doc); + let long_about = cmd + .find_subcommand("things") + .and_then(|things| things.find_subcommand("list")) + .and_then(|list| list.get_long_about()) + .map(ToString::to_string) + .expect("long_about missing"); + assert!( + long_about.chars().count() <= crate::text::CLI_LONG_DESCRIPTION_LIMIT, + "long_about was {} chars", + long_about.chars().count(), + ); + } + + /// A tag named after the group owns the group's description. When it + /// carries no prose, a sibling tag's prose must not stand in for it — + /// that names a subset of the group. Modeled on ElevenLabs' `voices` + /// group, whose ops carry `voices` (undocumented) and `pvc-voices`. + #[test] + fn test_group_name_tag_without_description_blocks_sibling_prose() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "pvc-things".to_string(), + "Create and manage professional clones.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["things".to_string(), "pvc-things".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("things".to_string(), 12), ("pvcthings".to_string(), 14)]), + ); + doc.group_operation_counts.insert("things".to_string(), 27); + doc.tag_group_names + .insert("pvcthings".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("things".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["pvc-things".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + /// A described tag the group declares less often than another tag + /// describes part of the group, so it does not get to name the whole + /// group even when it clears the coverage majority. + #[test] + fn test_minority_tag_does_not_describe_the_group() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("subset".to_string(), "Prose about a subset.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["primary".to_string(), "subset".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("primary".to_string(), 10), ("subset".to_string(), 6)]), + ); + doc.group_operation_counts.insert("things".to_string(), 10); + doc.tag_group_names + .insert("subset".to_string(), vec!["things".to_string()]); + doc.tag_group_names + .insert("primary".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["subset".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_colon_tag_is_not_borrowed_for_group_help() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions + .insert("access-all".to_string(), "Access everything.".to_string()); + doc.group_tag_names.insert( + "things".to_string(), + vec!["access:all".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([("accessall".to_string(), 1)]), + ); + doc.group_operation_counts.insert("things".to_string(), 1); + doc.tag_group_names + .insert("accessall".to_string(), vec!["things".to_string()]); + doc.tag_description_order = vec!["access-all".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'things'"), + ); + } + + #[test] + fn test_exclusive_tag_with_majority_coverage_is_selected() { + let mut doc = make_doc_with_things_resource(); + doc.tag_descriptions.insert( + "minority".to_string(), + "Minority description.".to_string(), + ); + doc.tag_descriptions.insert( + "majority".to_string(), + "Majority description.".to_string(), + ); + doc.group_tag_names.insert( + "things".to_string(), + vec!["minority".to_string(), "majority".to_string()], + ); + doc.group_tag_operation_counts.insert( + "things".to_string(), + HashMap::from([ + ("minority".to_string(), 1), + ("majority".to_string(), 3), + ]), + ); + doc.group_operation_counts.insert("things".to_string(), 4); + doc.tag_group_names.insert( + "minority".to_string(), + vec!["things".to_string()], + ); + doc.tag_group_names.insert( + "majority".to_string(), + vec!["things".to_string()], + ); + doc.tag_description_order = + vec!["minority".to_string(), "majority".to_string()]; + + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Majority description."), + ); + } + + #[test] + fn test_nested_resources_do_not_use_root_tag_descriptions() { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/voices".to_string(), + ..Default::default() + }, + ); + let mut nested = HashMap::new(); + nested.insert( + "voices".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "text-to-speech".to_string(), + RestResource { + methods: HashMap::new(), + resources: nested, + }, + ); + let mut doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + doc.tag_descriptions.insert( + "text-to-speech".to_string(), + "Convert text to speech.".to_string(), + ); + doc.tag_descriptions.insert( + "voices".to_string(), + "Manage voices.".to_string(), + ); + + let cmd = build_cli(&doc); + let top = cmd + .find_subcommand("text-to-speech") + .expect("top-level subcommand missing"); + assert_eq!( + top.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Convert text to speech.", + ); + let nested = top + .find_subcommand("voices") + .expect("nested subcommand missing"); + assert_eq!( + nested.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'voices'", + ); + } + + #[test] + fn test_group_summary_still_wins_over_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things summary".to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Description from the OpenAPI tag.".to_string(), + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things summary", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Description from the OpenAPI tag.", + ); + } + + #[test] + fn test_name_restating_summary_falls_through_to_tag_description() { + for summary in ["Things", "T-h_i.n g s", "THINGS"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things available to your account.", + "summary {summary:?} should fall through to the tag description", + ); + } + } + + #[test] + fn test_name_restating_summary_is_kept_without_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things", + ); + } + + #[test] + fn test_informative_and_plural_near_match_summaries_are_kept() { + for summary in ["Things summary", "Thing"] { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some(summary.to_string()), + description: None, + }, + ); + doc.tag_descriptions.insert( + "things".to_string(), + "Manage the things available to your account.".to_string(), + ); + + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + summary, + "summary {summary:?} should remain authoritative", + ); + } + } + + #[test] + fn test_long_tag_description_is_truncated_for_about() { + let mut doc = make_doc_with_things_resource(); + let description = "This is a deliberately long group description that contains enough prose to exceed the CLI help description limit. It has multiple sentences so the about line should stop at a sensible sentence boundary while long_about retains the full original text. The remainder makes the fixture unambiguously longer than the terminal-friendly limit."; + doc.tag_descriptions + .insert("things".to_string(), description.to_string()); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + crate::text::truncate_description( + &crate::text::first_sentence(description), + crate::text::CLI_SHORT_DESCRIPTION_LIMIT, + true + ), + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + description, + ); + } + + /// `description` alone (no `summary`) used to leave the command table + /// showing `Operations on ''`, with the prose reachable only from + /// `--help`. That asymmetry stopped making sense once tag prose could + /// reach the table: a group with no Fern configuration at all would get + /// a real table line from its tag while a group whose owner had written + /// a description explicitly would not. `description` is the more + /// authoritative of the two, so it now drives the table line as well. + /// A single-sentence description that already fits needs no `long_about`. + #[test] + fn test_group_description_drives_about_and_is_not_repeated_in_long_about() { let mut doc = make_doc_with_things_resource(); doc.groups.insert( "things".to_string(), @@ -2013,14 +2984,62 @@ mod tests { .expect("things subcommand missing"); assert_eq!( things.get_about().map(|s| s.to_string()).unwrap_or_default(), - "Operations on 'things'", + "Long-form prose about things.", + ); + assert!(things.get_long_about().is_none()); + } + + /// Only the first sentence of a configured description reaches the + /// table; the full prose stays available under `--help`. + #[test] + fn test_multi_sentence_group_description_splits_across_about_and_long_about() { + let description = "Manage the things in your account. Each thing carries its own settings, and deleting one cannot be undone."; + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some(description.to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Manage the things in your account.", ); assert_eq!( things .get_long_about() .map(|s| s.to_string()) .unwrap_or_default(), - "Long-form prose about things.", + description, + ); + } + + /// A configured description outranks prose inferred from the spec's + /// tags, matching the precedence the agent-skill emitter already uses. + #[test] + fn test_group_description_outranks_tag_description() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Configured prose wins.".to_string()), + }, + ); + doc.tag_descriptions + .insert("things".to_string(), "Tag prose loses.".to_string()); + let cmd = build_cli(&doc); + assert_eq!( + cmd.find_subcommand("things") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Configured prose wins."), ); } @@ -2219,6 +3238,35 @@ paths: assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); } + /// Multipart field prose reached `--help` untruncated while every other + /// parameter was capped, and spec indentation survived into the help + /// column. Both are now handled like any other flag. + #[test] + fn test_multipart_field_help_is_capped_and_whitespace_collapsed() { + let field = crate::openapi::discovery::MultipartField { + wire_name: "keyterms".to_string(), + is_file: false, + description: Some( + "A list of keyterms to bias the transcription towards. The keyterms are words or phrases you want the model to recognise more accurately. The number of keyterms cannot exceed 1000. Each keyterm must be under 50 characters." + .to_string(), + ), + required: false, + content_type: None, + }; + let arg = build_multipart_field_arg(&field); + let short = arg.get_help().expect("short help missing").to_string(); + let long = arg.get_long_help().expect("long help missing").to_string(); + assert_eq!(short, "A list of keyterms to bias the transcription towards."); + assert!( + !long.contains(" "), + "spec indentation should be collapsed; got: {long}", + ); + assert!( + long.ends_with("Each keyterm must be under 50 characters."), + "long help should keep the pricing/constraint clauses; got: {long}", + ); + } + #[test] fn test_multipart_field_builtin_collision_skipped() { use crate::openapi::discovery::MultipartField; diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/discovery.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/discovery.rs index e2dd6822eaf3..0cf0db96e0dd 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/discovery.rs @@ -124,6 +124,25 @@ pub struct RestDescription { /// existing groups for documentation. #[serde(default, skip)] pub groups: HashMap, + /// Descriptions from the document-root OpenAPI `tags` array, keyed by + /// kebab-cased tag name so they match tag-derived resource keys. + #[serde(default, skip)] + pub tag_descriptions: HashMap, + #[serde(default, skip)] + pub group_tag_names: HashMap>, + /// Number of operations in each top-level group that declare each tag, + /// keyed by the lenient tag matching key. + #[serde(default, skip)] + pub group_tag_operation_counts: HashMap>, + /// Number of operations in each top-level group. + #[serde(default, skip)] + pub group_operation_counts: HashMap, + /// Top-level groups carrying each operation-declared tag, keyed by the + /// lenient tag matching key. + #[serde(default, skip)] + pub tag_group_names: HashMap>, + #[serde(default, skip)] + pub tag_description_order: Vec, } /// Metadata for a single group declared via the spec-root @@ -604,6 +623,12 @@ impl RetriesConfig { pub struct RestMethod { pub id: Option, pub description: Option, + /// The operation's full `description` when it says more than + /// [`RestMethod::description`] (which prefers the terse `summary`). + /// `None` when the spec has no separate prose, so the command table and + /// the command's own `--help` would otherwise repeat one line. + #[serde(default)] + pub long_description: Option, pub http_method: String, pub path: String, #[serde(default)] diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs index 361d8b9ecd41..37cff6aa2530 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/parser.rs @@ -3,7 +3,7 @@ //! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` //! representation used by the CLI command builder and executor. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Deserializer}; @@ -176,6 +176,11 @@ struct OpenApiSpec { info: OpenApiInfo, #[serde(default)] servers: Vec, + /// OpenAPI's document-root tags are optional metadata for generated + /// groups. Invalid or primitive entries are skipped by the lenient + /// deserializer below. + #[serde(default, deserialize_with = "deserialize_openapi_tags")] + tags: OpenApiTagMetadata, #[serde(default)] paths: HashMap, /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations @@ -238,6 +243,71 @@ struct OpenApiSpec { x_fern_groups: Option>, } +/// Deserialize document-root OpenAPI tags into the normalized metadata used +/// by the CLI help surface. Some real-world specs contain primitive entries +/// in this array, so malformed entries are skipped rather than rejecting the +/// whole document. +fn deserialize_openapi_tags<'de, D>( + deserializer: D, +) -> Result +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(OpenApiTagMetadata::default()); + }; + let serde_yaml::Value::Sequence(entries) = value else { + tracing::debug!("Skipping document-root OpenAPI tags because the value is not an array"); + return Ok(OpenApiTagMetadata::default()); + }; + + let mut descriptions = HashMap::new(); + let mut order = Vec::new(); + for entry in entries { + match serde_yaml::from_value::(entry) { + Ok(tag) if !tag.name.trim().is_empty() => { + if let Some(description) = tag.description.filter(|d| !d.trim().is_empty()) { + let normalized_name = camel_to_kebab(&tag.name); + order.push(normalized_name.clone()); + match descriptions.entry(normalized_name) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(description); + } + std::collections::hash_map::Entry::Occupied(entry) => { + tracing::debug!( + tag_name = %tag.name, + normalized_name = %entry.key(), + "Keeping the first document-root OpenAPI tag description after normalization collision" + ); + } + } + } + } + Ok(_) => { + tracing::debug!("Skipping document-root OpenAPI tag with an empty name"); + } + Err(error) => { + tracing::debug!(%error, "Skipping malformed document-root OpenAPI tag"); + } + } + } + Ok(OpenApiTagMetadata { descriptions, order }) +} + +#[derive(Debug, Default)] +struct OpenApiTagMetadata { + descriptions: HashMap, + order: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawOpenApiTag { + name: String, + #[serde(default)] + description: Option, +} + /// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. /// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` /// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). @@ -2635,6 +2705,12 @@ pub fn load_openapi_spec_from_value( global_parameters, global_headers, groups, + tag_descriptions: spec.tags.descriptions, + group_tag_names: HashMap::new(), + group_tag_operation_counts: HashMap::new(), + group_operation_counts: HashMap::new(), + tag_group_names: HashMap::new(), + tag_description_order: spec.tags.order, ..Default::default() }; @@ -2812,10 +2888,23 @@ pub fn load_openapi_spec_from_value( params.entry(name).or_insert(param); } + // `summary` is the terse label and wins for the command table. + // `description` is the prose; keep it separately rather than + // discarding it, so ` --help` has something to show + // beyond the table line. Dropped when it adds nothing. let description = operation .summary .clone() .or_else(|| operation.description.clone()); + let long_description = operation + .description + .clone() + .filter(|prose| !prose.trim().is_empty()) + .filter(|prose| { + description + .as_deref() + .is_none_or(|summary| prose_adds_detail(prose, summary)) + }); let method_root_url = operation.servers .first() @@ -2955,6 +3044,7 @@ pub fn load_openapi_spec_from_value( let rest_method = RestMethod { id: operation.operation_id.clone(), description, + long_description, http_method: http_method.to_string(), path: path.clone(), parameters: params, @@ -2982,8 +3072,44 @@ pub fn load_openapi_spec_from_value( // Walk group_name to create/find nested resources let kebab_groups: Vec = group_name.iter().map(|g| camel_to_kebab(g)).collect(); + let operation_tags = operation.tags.as_deref().unwrap_or(&[]); + if let Some(top_level_group) = kebab_groups.first() { + *doc.group_operation_counts + .entry(top_level_group.clone()) + .or_default() += 1; + append_unique_tags( + doc.group_tag_names + .entry(top_level_group.clone()) + .or_default(), + operation_tags, + ); + let tag_counts = doc + .group_tag_operation_counts + .entry(top_level_group.clone()) + .or_default(); + let mut counted_tags = HashSet::new(); + for tag in operation_tags { + let tag_key = tag_match_key(tag); + if counted_tags.insert(tag_key.clone()) { + *tag_counts.entry(tag_key.clone()).or_default() += 1; + } + append_unique_tags( + doc.tag_group_names + .entry(tag_key) + .or_default(), + std::slice::from_ref(top_level_group), + ); + } + } - insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + insert_method_into_resources( + &mut doc.resources, + &kebab_groups, + &method_name, + rest_method, + operation_tags, + &mut doc.group_tag_names, + ); } } @@ -3011,6 +3137,8 @@ fn insert_method_into_resources( groups: &[String], method_name: &str, method: RestMethod, + tags: &[String], + group_tag_names: &mut HashMap>, ) { if groups.is_empty() { return; @@ -3019,12 +3147,55 @@ fn insert_method_into_resources( let resource = resources .entry(groups[0].clone()) .or_default(); + append_unique_tags(group_tag_names.entry(groups[0].clone()).or_default(), tags); if groups.len() == 1 { resource.methods.insert(method_name.to_string(), method); } else { - insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + insert_method_into_resources( + &mut resource.resources, + &groups[1..], + method_name, + method, + tags, + group_tag_names, + ); + } +} + +fn append_unique_tags(existing: &mut Vec, incoming: &[String]) { + for tag in incoming { + if !existing.iter().any(|existing_tag| existing_tag == tag) { + existing.push(tag.clone()); + } + } +} + +/// Whether an operation's `description` says more than its `summary`, rather +/// than restating it in different words. +/// +/// `--help` is meant to elaborate on `-h`. Specs commonly carry a paraphrase +/// in `description` ("Audio isolation" / "Removes background noise from +/// audio."), and promoting one of those makes the two tiers look like they +/// describe different commands. A description earns the long slot by adding +/// a further sentence or a substantial clause. +fn prose_adds_detail(prose: &str, summary: &str) -> bool { + const MIN_ADDED_CHARS: usize = 40; + let prose = crate::text::collapse_whitespace(prose); + let summary = crate::text::collapse_whitespace(summary); + if prose.eq_ignore_ascii_case(&summary) { + return false; } + let multi_sentence = crate::text::first_sentence(&prose).len() < prose.trim_end().len(); + multi_sentence || prose.chars().count() >= summary.chars().count() + MIN_ADDED_CHARS +} + +fn tag_match_key(value: &str) -> String { + value + .chars() + .filter(|c| c.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() } /// Extract request body info from an OpenAPI requestBody. @@ -6857,6 +7028,345 @@ paths: ); } + /// `--help` elaborates on `-h`; it does not restate it. A description + /// that only paraphrases the summary in the same breath earns no long + /// slot, or the two tiers read like different commands. + #[test] + fn test_paraphrasing_description_is_not_promoted_to_long_help() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /groups: + get: + x-fern-sdk-group-name: [groups] + x-fern-sdk-method-name: list + operationId: groups_list + summary: List workspace groups + description: Get all groups in the workspace + responses: + "200": + description: ok + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: Audio isolation + description: Removes background noise from audio. Returns the isolated speech track. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Same length, different words — a paraphrase, so no long form. + let groups = first_method(&doc, "groups", "list"); + assert_eq!(groups.description.as_deref(), Some("List workspace groups")); + assert_eq!(groups.long_description, None); + // A second sentence is real elaboration and is kept. + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("Audio isolation")); + assert_eq!( + things.long_description.as_deref(), + Some("Removes background noise from audio. Returns the isolated speech track."), + ); + } + + #[test] + fn test_operation_summary_and_description_are_kept_separately() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + summary: List things + description: Returns every thing visible to the caller, newest first. + responses: + "200": + description: ok + /others: + get: + x-fern-sdk-group-name: [others] + x-fern-sdk-method-name: list + operationId: others_list + description: Only prose, no summary. + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let things = first_method(&doc, "things", "list"); + assert_eq!(things.description.as_deref(), Some("List things")); + assert_eq!( + things.long_description.as_deref(), + Some("Returns every thing visible to the caller, newest first."), + ); + // With no summary the prose already is the description, so keeping a + // second copy would only duplicate the line in help output. + let others = first_method(&doc, "others", "list"); + assert_eq!(others.description.as_deref(), Some("Only prose, no summary.")); + assert_eq!(others.long_description, None); + } + + #[test] + fn test_root_tag_descriptions_are_indexed_by_kebab_case() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: Description for the group. + - name: no-description + x-displayName: Display label only +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("Description for the group."), + ); + assert!(!doc.tag_descriptions.contains_key("no-description")); + } + + #[test] + fn test_root_tag_description_normalization_collision_keeps_first() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: myGroup + description: First description. + - name: my-group + description: Second description. +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.tag_descriptions.get("my-group").map(String::as_str), + Some("First description."), + ); + } + + #[test] + fn test_string_array_tags_are_skipped() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: [customers, orders] +paths: {} +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.tag_descriptions.is_empty()); + } + + #[test] + fn test_group_descriptions_follow_operation_tags_when_names_differ() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: speech-history + description: Speech history description. + - name: music-generation + description: Music generation description. + - name: Pronunciation Dictionary + description: Pronunciation dictionary description. + - name: Conversational AI + description: Conversational AI description. +paths: + /history: + get: + tags: [speech-history] + x-fern-sdk-group-name: [history] + operationId: history_list + responses: + "200": + description: ok + /music: + get: + tags: [music-generation] + x-fern-sdk-group-name: [music] + operationId: music_list + responses: + "200": + description: ok + /pronunciation-dictionaries: + get: + tags: [Pronunciation Dictionary] + x-fern-sdk-group-name: [pronunciation-dictionaries] + operationId: pronunciation_list + responses: + "200": + description: ok + /conversational-ai: + get: + tags: [Conversational AI] + x-fern-sdk-group-name: [conversational-ai] + operationId: conversational_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let resource_names: std::collections::BTreeSet = + doc.resources.keys().cloned().collect(); + assert_eq!( + resource_names, + [ + "conversational-ai", + "history", + "music", + "pronunciation-dictionaries", + ] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.group_tag_names.get("history"), + Some(&vec!["speech-history".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("music"), + Some(&vec!["music-generation".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("pronunciation-dictionaries"), + Some(&vec!["Pronunciation Dictionary".to_string()]) + ); + assert_eq!( + doc.group_tag_names.get("conversational-ai"), + Some(&vec!["Conversational AI".to_string()]) + ); + assert_eq!( + doc.group_operation_counts.get("history"), + Some(&1), + ); + assert_eq!( + doc.group_tag_operation_counts + .get("history") + .and_then(|counts| counts.get("speechhistory")), + Some(&1), + ); + + let cli = crate::openapi::commands::build_cli(&doc); + for (group, description) in [ + ("history", "Speech history description."), + ("music", "Music generation description."), + ( + "pronunciation-dictionaries", + "Pronunciation dictionary description.", + ), + ("conversational-ai", "Conversational AI description."), + ] { + assert_eq!( + cli.find_subcommand(group) + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some(description) + ); + } + } + + #[test] + fn test_shared_tags_are_rejected_but_group_name_tags_win() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +tags: + - name: shared + description: Shared description. + - name: Named Group + description: Named group description. +paths: + /first: + get: + tags: [shared] + x-fern-sdk-group-name: [first] + operationId: first_list + responses: + "200": + description: ok + /second: + get: + tags: [shared] + x-fern-sdk-group-name: [second] + operationId: second_list + responses: + "200": + description: ok + /named-group: + get: + tags: [shared, Named Group] + x-fern-sdk-group-name: [named-group] + operationId: named_group_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let shared_groups = doc + .tag_group_names + .get("shared") + .cloned() + .unwrap_or_default() + .into_iter() + .collect::>(); + assert_eq!( + shared_groups, + ["first", "second", "named-group"] + .into_iter() + .map(String::from) + .collect() + ); + assert_eq!( + doc.tag_group_names.get("namedgroup"), + Some(&vec!["named-group".to_string()]) + ); + + let cli = crate::openapi::commands::build_cli(&doc); + assert_eq!( + cli.find_subcommand("first") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'first'") + ); + assert_eq!( + cli.find_subcommand("second") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Operations on 'second'") + ); + assert_eq!( + cli.find_subcommand("named-group") + .and_then(|command| command.get_about()) + .map(ToString::to_string) + .as_deref(), + Some("Named group description.") + ); + } + /// Unrelated extra fields inside a group entry are ignored /// rather than rejected. Fern's `getFernGroups.ts` schema is a /// `z.object({ summary, description })` (no `.strict()`), so the diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/skill_emitter.rs index 537f8b401df8..5b6ddaf7086b 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/skill_emitter.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/openapi/skill_emitter.rs @@ -15,6 +15,7 @@ use std::path::PathBuf; use clap::{Arg, Command}; use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::commands::group_tag_description_for_group; use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; use crate::text; @@ -373,16 +374,41 @@ fn render_group_skill( } fn group_description(doc: &RestDescription, group_name: &str) -> String { - // Try x-fern-groups metadata first - if let Some(info) = doc.groups.get(group_name) { - if let Some(ref summary) = info.summary { - return summary.clone(); - } - if let Some(ref description) = info.description { - return first_sentence(description); + let tag_description = group_tag_description_for_group( + group_name, + doc.group_tag_names.get(group_name).map(Vec::as_slice), + &doc.tag_descriptions, + doc.group_tag_operation_counts.get(group_name), + doc.group_operation_counts.get(group_name).copied(), + &doc.tag_group_names, + &doc.tag_description_order, + ) + .filter(|description| !description.is_empty()); + + if let Some(summary) = doc + .groups + .get(group_name) + .and_then(|info| info.summary.as_deref()) + .filter(|summary| !summary.is_empty()) + { + if !text::is_name_restating(summary, group_name) || tag_description.is_none() { + return summary.to_string(); } } + if let Some(description) = doc + .groups + .get(group_name) + .and_then(|info| info.description.as_deref()) + .filter(|description| !description.is_empty()) + { + return text::first_sentence(description); + } + + if let Some(description) = tag_description { + return description; + } + // Fall back to spec title/description if let Some(ref title) = doc.title { return format!("{title}: Operations on {group_name}"); @@ -390,14 +416,6 @@ fn group_description(doc: &RestDescription, group_name: &str) -> String { format!("Operations on {group_name}") } -fn first_sentence(s: &str) -> String { - if let Some(idx) = s.find(". ") { - s[..=idx].to_string() - } else { - s.to_string() - } -} - fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { // Render methods at this level — sorted let mut method_names: Vec<&String> = resource.methods.keys().collect(); @@ -481,7 +499,9 @@ pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) - mod tests { use super::*; use std::collections::HashMap; - use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + use crate::openapi::discovery::{ + MethodParameter, RestDescription, RestMethod, RestResource, SdkGroupInfo, + }; fn minimal_doc() -> RestDescription { let mut resources = HashMap::new(); @@ -732,6 +752,68 @@ mod tests { } } + #[test] + fn tag_description_drives_group_skill_description() { + let mut doc = minimal_doc(); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + } + + #[test] + fn name_restating_summary_falls_through_to_tag_description() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + summary: Some("Items".to_string()), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Manage the items available to your account.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage the items available to your account.\"")); + assert!(!files[1].1.contains("description: \"Items\"")); + } + + #[test] + fn group_description_wins_over_tag_description_when_summary_is_absent() { + let mut doc = minimal_doc(); + doc.groups.insert( + "items".to_string(), + SdkGroupInfo { + description: Some( + "Manage item inventory across every connected account. This is extended prose." + .to_string(), + ), + ..Default::default() + }, + ); + doc.tag_descriptions.insert( + "items".to_string(), + "Items exposed by the API.".to_string(), + ); + + let files = generate_skills(&doc, "test", &[]); + assert!(files[1] + .1 + .contains("description: \"Manage item inventory across every connected account.\"")); + assert!(!files[1] + .1 + .contains("description: \"Items exposed by the API.\"")); + } + #[test] fn frontmatter_description_escapes_quotes() { let mut resources = HashMap::new(); diff --git a/seed/cli/x-fern-global-parameters/no-custom-config/src/text.rs b/seed/cli/x-fern-global-parameters/no-custom-config/src/text.rs index 9b4df3bb05ec..ec69faba4869 100644 --- a/seed/cli/x-fern-global-parameters/no-custom-config/src/text.rs +++ b/seed/cli/x-fern-global-parameters/no-custom-config/src/text.rs @@ -5,6 +5,105 @@ use unicode_normalization::UnicodeNormalization; /// Max chars for CLI `--help` method descriptions (terminal-width friendly). pub const CLI_DESCRIPTION_LIMIT: usize = 200; +/// Max chars for single-line CLI `--help` descriptions. +pub const CLI_SHORT_DESCRIPTION_LIMIT: usize = 80; + +/// Max chars for the detailed text rendered under `--help` — both a +/// command's own description and its flags'. +/// +/// `--help` is the thorough tier: whatever the spec documents about a flag +/// (constraints, pricing, examples) belongs there in full, so this is set +/// well above anything real specs contain rather than as an editorial +/// trim. It exists only so a pathological spec cannot flood the terminal; +/// [`CLI_SHORT_DESCRIPTION_LIMIT`] is what keeps `-h` scannable. +pub const CLI_LONG_DESCRIPTION_LIMIT: usize = 2000; + +/// Abbreviations that end in a period without ending a sentence. Splitting +/// on them truncates help text mid-phrase (`Fetch a user, e.g.`), so they +/// are never treated as sentence boundaries. +const NON_TERMINAL_ABBREVIATIONS: &[&str] = &[ + "al", "approx", "ca", "cf", "co", "corp", "dept", "dr", "eg", "esp", "etc", "ex", "fig", "ie", + "inc", "jr", "ltd", "max", "min", "mr", "mrs", "ms", "no", "prof", "resp", "sr", "st", "vol", + "vs", +]; + +/// Return whether the period at `period_index` ends a sentence rather than +/// an abbreviation, an initial, or a dotted token like `U.S.` or `v1.2`. +/// +/// Callers are responsible for the "followed by a space or end of input" +/// half of the check — this decides the ambiguous cases that rule alone +/// gets wrong. +fn is_sentence_boundary(chars: &[char], period_index: usize) -> bool { + debug_assert_eq!(chars.get(period_index), Some(&'.')); + + // The token this period closes, back to the preceding whitespace, with + // any opening punctuation (`(e.g.`) stripped. + let token_start = chars[..period_index] + .iter() + .rposition(|c| c.is_whitespace()) + .map_or(0, |index| index + 1); + let token: String = chars[token_start..period_index].iter().collect(); + let word = token.trim_start_matches(|c: char| !c.is_alphanumeric()); + + if word.is_empty() + // Interior periods mark a dotted abbreviation (`e.g.`, `U.S.`) or a + // version (`v1.2`), never the end of a sentence. + || word.contains('.') + // A lone letter is an initial (`J. Smith`). + || word.chars().count() == 1 + || NON_TERMINAL_ABBREVIATIONS + .iter() + .any(|abbreviation| word.eq_ignore_ascii_case(abbreviation)) + { + return false; + } + + // Prose resumes with a capital (or a digit). A lowercase continuation + // means the period belonged to the phrase — an abbreviation this list + // doesn't know about. + match chars[period_index + 1..].iter().find(|c| !c.is_whitespace()) { + Some(next) => !next.is_lowercase(), + None => true, + } +} + +/// Collapse runs of whitespace (including newlines) into single spaces. +/// +/// Specs routinely carry hand-indented prose whose leading whitespace +/// survives YAML block scalars; rendered verbatim in a help column it shows +/// up as long gaps mid-sentence. +pub fn collapse_whitespace(s: &str) -> String { + s.split_whitespace().collect::>().join(" ") +} + +/// Return the first sentence of prose, with embedded whitespace normalized. +pub fn first_sentence(s: &str) -> String { + let normalized = collapse_whitespace(s); + let chars: Vec = normalized.chars().collect(); + for index in 0..chars.len() { + if chars[index] == '.' + && chars.get(index + 1) == Some(&' ') + && is_sentence_boundary(&chars, index) + { + return chars[..=index].iter().collect(); + } + } + normalized +} + +/// Return whether a summary merely restates a command name, ignoring case +/// and non-alphanumeric characters. +pub fn is_name_restating(summary: &str, name: &str) -> bool { + let normalize = |value: &str| { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::() + }; + normalize(summary) == normalize(name) +} + /// Convert a parameter name to an idiomatic kebab-case CLI flag. /// /// Handles snake_case (`min_start_time` → `min-start-time`), camelCase @@ -279,9 +378,11 @@ fn find_last_sentence_boundary(prefix: &str) -> Option { for (i, _) in chars.iter().enumerate() { if chars[i] == '.' { let after_period = i + 1; - // Sentence boundary: period followed by a space, or period at end of prefix - if after_period == chars.len() - || (after_period < chars.len() && chars[after_period] == ' ') + // Sentence boundary: period followed by a space, or period at + // end of prefix — and not an abbreviation's period. + if (after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ')) + && is_sentence_boundary(&chars, i) { last_boundary = Some(after_period); } @@ -466,6 +567,65 @@ mod tests { assert_eq!(to_screaming_snake(""), ""); } + #[test] + fn test_first_sentence_does_not_split_on_abbreviations() { + assert_eq!( + first_sentence("Fetch a user by id, e.g. usr_123, from the directory."), + "Fetch a user by id, e.g. usr_123, from the directory." + ); + assert_eq!( + first_sentence("Returns items (i.e. songs, albums) for the user."), + "Returns items (i.e. songs, albums) for the user." + ); + assert_eq!( + first_sentence("Upload a file to the U.S. region bucket. Returns a handle."), + "Upload a file to the U.S. region bucket." + ); + assert_eq!( + first_sentence("Reads at most 100 items, etc. Additional pages need a cursor."), + "Reads at most 100 items, etc. Additional pages need a cursor." + ); + assert_eq!( + first_sentence("Written by J. Smith. Deprecated."), + "Written by J. Smith." + ); + } + + #[test] + fn test_first_sentence_splits_on_real_boundaries() { + assert_eq!( + first_sentence("Deletes the voice. This cannot be undone."), + "Deletes the voice." + ); + assert_eq!( + first_sentence("Deletes the voice.\nThis cannot be undone."), + "Deletes the voice." + ); + assert_eq!(first_sentence("No trailing period"), "No trailing period"); + assert_eq!(first_sentence(""), ""); + } + + #[test] + fn test_truncate_description_does_not_cut_at_abbreviations() { + let description = "Fetch a user by id, e.g. usr_123, from the directory of every \ +registered account in the workspace."; + let truncated = truncate_description(description, CLI_SHORT_DESCRIPTION_LIMIT, true); + assert!( + !truncated.ends_with("e.g."), + "truncated at an abbreviation: {truncated}" + ); + assert!(truncated.ends_with('…'), "expected a word-boundary cut: {truncated}"); + } + + #[test] + fn test_is_name_restating() { + assert!(is_name_restating("Models", "models")); + assert!(is_name_restating("Text To Speech", "text-to-speech")); + assert!(is_name_restating("Audio_Isolation", "audio-isolation")); + assert!(!is_name_restating("Pronunciation Dictionary", "pronunciation-dictionaries")); + assert!(!is_name_restating("Manage models", "models")); + } + // ------------------------------------------------------------------ // sanitize_flag_name — FER-10430 parametrized table // ------------------------------------------------------------------ From 415b53fad05b5a29a3e2a7a79e9ffe48ec6b6536 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 21 Aug 2026 00:37:07 +0000 Subject: [PATCH 2/4] chore(cli-generator): release 0.36.0 --- ...penapi-tag-descriptions-for-cli-groups.yml | 0 generators/cli/versions.yml | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+) rename generators/cli/changes/{unreleased => 0.36.0}/use-openapi-tag-descriptions-for-cli-groups.yml (100%) diff --git a/generators/cli/changes/unreleased/use-openapi-tag-descriptions-for-cli-groups.yml b/generators/cli/changes/0.36.0/use-openapi-tag-descriptions-for-cli-groups.yml similarity index 100% rename from generators/cli/changes/unreleased/use-openapi-tag-descriptions-for-cli-groups.yml rename to generators/cli/changes/0.36.0/use-openapi-tag-descriptions-for-cli-groups.yml diff --git a/generators/cli/versions.yml b/generators/cli/versions.yml index 523a52c8b29d..c9e6db8d80aa 100644 --- a/generators/cli/versions.yml +++ b/generators/cli/versions.yml @@ -1,4 +1,36 @@ # yaml-language-server: $schema=../../fern-versions-yml.schema.json +- version: 0.36.0 + changelogEntry: + - summary: | + Use safe OpenAPI root tag descriptions for generated CLI group help and agent + skills when no x-fern-groups summary is provided. Match operation-declared + tags to groups without borrowing ambiguous or scope-style descriptions, and + keep CLI help table lines concise while preserving full prose in long help. + Treat summaries that only restate a group's command name as non-informative + when a usable tag description exists, while retaining them when no such + description is available. Preserve framework built-in group descriptions + when a colliding API group has only the generic fallback. Method help now + keeps the fuller description under ` --help` while the command + table stays a single short sentence, and sentence splitting no longer cuts + help text at abbreviations such as `e.g.`, `i.e.`, or `U.S.`. A tag named + after the group owns that group's description: when it documents nothing, + a sibling tag's prose no longer stands in for it, and a tag the group + declares less often than another never names the whole group. An operation's + `description` is no longer discarded in favour of its terse `summary`: the + summary still labels the command table while the prose is what the command + renders under its own `--help` — but only when it elaborates rather than + paraphrasing the summary, so the two tiers never read as different + commands. Flag help is split the same way — `-h` + shows a one-line form and `--help` the fuller prose — and multipart field + help, which previously bypassed truncation entirely, is now capped and has + its spec indentation collapsed like every other parameter. `--help` keeps + whatever the spec documents in full; only `-h` trims. An explicit + `x-fern-groups.description` now drives the group's command-table line too + (first sentence, with the full prose kept in long help) instead of only + populating long help while the table showed the generic label. + type: feat + createdAt: "2026-08-21" + irVersion: 67 - version: 0.35.4 changelogEntry: - summary: | From ec9199981f852bcb1df92c61e6938e6e97fe16d3 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:17:22 +0000 Subject: [PATCH 3/4] fix(cli): bump @fern-api/fdr-sdk to pick up the scope-segment slug fix (#17496) --- pnpm-lock.yaml | 88 ++++++++++++++++++++++----------------------- pnpm-workspace.yaml | 4 +-- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40e91a2d1e21..9b5a13a59f1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,7 +587,7 @@ overrides: minimatch: '>=10.2.3' qs: 6.15.2 url-join: ^4.0.1 - '@fern-api/fdr-sdk': 1.2.96-e7835c2e07 + '@fern-api/fdr-sdk': 1.2.96-066f6aa406 form-data: ^4.0.6 '@fern-api/ui-core-utils': 0.145.12-b50d999d1 vite: ^7.3.5 @@ -3915,8 +3915,8 @@ importers: packages/cli/api-importers/graphql: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -4291,8 +4291,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-generator': specifier: workspace:* version: link:../../generation/ir-generator @@ -4418,8 +4418,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-formatter': specifier: workspace:* version: link:../fern-definition/formatter @@ -4876,8 +4876,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5081,8 +5081,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5118,8 +5118,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5197,8 +5197,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5237,8 +5237,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5277,8 +5277,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5344,8 +5344,8 @@ importers: packages/cli/docs-markdown-utils: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5435,8 +5435,8 @@ importers: specifier: workspace:* version: link:../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5550,8 +5550,8 @@ importers: specifier: workspace:* version: link:../docs-markdown-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5653,8 +5653,8 @@ importers: specifier: workspace:* version: link:../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6361,8 +6361,8 @@ importers: specifier: 'catalog:' version: 0.0.6-2ee1b7e28 '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../../commons/fs-utils @@ -6586,8 +6586,8 @@ importers: specifier: workspace:* version: link:../generation/local-generation/docker-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6832,8 +6832,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -7028,8 +7028,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-sdk': specifier: workspace:* version: link:../../../ir-sdk @@ -7113,8 +7113,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -7359,8 +7359,8 @@ importers: specifier: workspace:* version: link:../../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -8004,8 +8004,8 @@ importers: packages/core: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-e7835c2e07 - version: 1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-066f6aa406 + version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/venus-api-sdk': specifier: 'catalog:' version: 5.0.0 @@ -9558,8 +9558,8 @@ packages: resolution: {integrity: sha512-3qhAAuc4ZJWLaFtyZzaYXfF9OQ5iviNrvLDXtjKScKUNS134fR3v3c3xedCidTq5KedapuBECziUaOmmd6KXVA==} engines: {node: '>=18.0.0'} - '@fern-api/fdr-sdk@1.2.96-e7835c2e07': - resolution: {integrity: sha512-KNP3ifbZzpOd2U1fkXudVz+p7+KqRzzfUtw0594M+sK/x+UdYyatFW8qQq7N2ilKFbJUZZS0Lnzq1pA36/jmRg==} + '@fern-api/fdr-sdk@1.2.96-066f6aa406': + resolution: {integrity: sha512-ch42dIo0rAoAc7yd3Npf6HxeFHcWANX2wWILbVALSpUvPGEHE4oI7OFztdBfVdIDuYF3cv3SfnGQe8qqjIYIdg==} '@fern-api/generator-cli@0.9.53': resolution: {integrity: sha512-737p5KbB8DZQFhU3JhW6hvI2If2O8ySDUb4bWXB1OzFT/dhAqSxhCDpAp9vrQH53ldtEEzAYqR44yAxw/0wUgA==} @@ -16523,7 +16523,7 @@ snapshots: '@fern-api/fai-sdk@0.0.6-2ee1b7e28': {} - '@fern-api/fdr-sdk@1.2.96-e7835c2e07(@opentelemetry/api@1.9.1)(typescript@5.9.3)': + '@fern-api/fdr-sdk@1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3)': dependencies: '@fern-api/ui-core-utils': 0.145.12-b50d999d1 '@orpc/client': 1.13.9(@opentelemetry/api@1.9.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 20ea147802f6..07041f6d1044 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,7 +61,7 @@ catalog: "@bufbuild/protobuf": ^2.2.5 "@bufbuild/protoplugin": 2.2.5 "@fern-api/fai-sdk": 0.0.6-2ee1b7e28 - "@fern-api/fdr-sdk": 1.2.96-e7835c2e07 + "@fern-api/fdr-sdk": 1.2.96-066f6aa406 "@fern-api/generator-cli": 0.9.53 "@fern-api/ui-core-utils": 0.129.4-b6c699ad2 "@fern-api/venus-api-sdk": 5.0.0 @@ -263,7 +263,7 @@ overrides: minimatch: ">=10.2.3" qs: 6.15.2 url-join: ^4.0.1 - "@fern-api/fdr-sdk": 1.2.96-e7835c2e07 + "@fern-api/fdr-sdk": 1.2.96-066f6aa406 form-data: ^4.0.6 "@fern-api/ui-core-utils": 0.145.12-b50d999d1 vite: ^7.3.5 From e2a4ebbe31ee283161be0bdb3ae9a6531ed7e27f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 04:26:51 +0000 Subject: [PATCH 4/4] fix(cli): bump @fern-api/fdr-sdk to pick up the scope-equal tab slug fix (#17497) --- pnpm-lock.yaml | 88 ++++++++++++++++++++++----------------------- pnpm-workspace.yaml | 4 +-- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b5a13a59f1b..4f3524eefdd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,7 +587,7 @@ overrides: minimatch: '>=10.2.3' qs: 6.15.2 url-join: ^4.0.1 - '@fern-api/fdr-sdk': 1.2.96-066f6aa406 + '@fern-api/fdr-sdk': 1.2.96-8e5f0d55dd form-data: ^4.0.6 '@fern-api/ui-core-utils': 0.145.12-b50d999d1 vite: ^7.3.5 @@ -3915,8 +3915,8 @@ importers: packages/cli/api-importers/graphql: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -4291,8 +4291,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-generator': specifier: workspace:* version: link:../../generation/ir-generator @@ -4418,8 +4418,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-formatter': specifier: workspace:* version: link:../fern-definition/formatter @@ -4876,8 +4876,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5081,8 +5081,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5118,8 +5118,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5197,8 +5197,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5237,8 +5237,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5277,8 +5277,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5344,8 +5344,8 @@ importers: packages/cli/docs-markdown-utils: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5435,8 +5435,8 @@ importers: specifier: workspace:* version: link:../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5550,8 +5550,8 @@ importers: specifier: workspace:* version: link:../docs-markdown-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5653,8 +5653,8 @@ importers: specifier: workspace:* version: link:../configuration '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6361,8 +6361,8 @@ importers: specifier: 'catalog:' version: 0.0.6-2ee1b7e28 '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../../commons/fs-utils @@ -6586,8 +6586,8 @@ importers: specifier: workspace:* version: link:../generation/local-generation/docker-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6832,8 +6832,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -7028,8 +7028,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-sdk': specifier: workspace:* version: link:../../../ir-sdk @@ -7113,8 +7113,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -7359,8 +7359,8 @@ importers: specifier: workspace:* version: link:../../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -8004,8 +8004,8 @@ importers: packages/core: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.96-066f6aa406 - version: 1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.96-8e5f0d55dd + version: 1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/venus-api-sdk': specifier: 'catalog:' version: 5.0.0 @@ -9558,8 +9558,8 @@ packages: resolution: {integrity: sha512-3qhAAuc4ZJWLaFtyZzaYXfF9OQ5iviNrvLDXtjKScKUNS134fR3v3c3xedCidTq5KedapuBECziUaOmmd6KXVA==} engines: {node: '>=18.0.0'} - '@fern-api/fdr-sdk@1.2.96-066f6aa406': - resolution: {integrity: sha512-ch42dIo0rAoAc7yd3Npf6HxeFHcWANX2wWILbVALSpUvPGEHE4oI7OFztdBfVdIDuYF3cv3SfnGQe8qqjIYIdg==} + '@fern-api/fdr-sdk@1.2.96-8e5f0d55dd': + resolution: {integrity: sha512-g4kfEIBohQJBCwYnTqQfY/Il3TY5tE4je2Us10UJAjFl8VcwQkGU/sq4kRbkx+BiHbZI/NP2USlWMBXjK7FtJA==} '@fern-api/generator-cli@0.9.53': resolution: {integrity: sha512-737p5KbB8DZQFhU3JhW6hvI2If2O8ySDUb4bWXB1OzFT/dhAqSxhCDpAp9vrQH53ldtEEzAYqR44yAxw/0wUgA==} @@ -16523,7 +16523,7 @@ snapshots: '@fern-api/fai-sdk@0.0.6-2ee1b7e28': {} - '@fern-api/fdr-sdk@1.2.96-066f6aa406(@opentelemetry/api@1.9.1)(typescript@5.9.3)': + '@fern-api/fdr-sdk@1.2.96-8e5f0d55dd(@opentelemetry/api@1.9.1)(typescript@5.9.3)': dependencies: '@fern-api/ui-core-utils': 0.145.12-b50d999d1 '@orpc/client': 1.13.9(@opentelemetry/api@1.9.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07041f6d1044..1f437b0c0257 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -61,7 +61,7 @@ catalog: "@bufbuild/protobuf": ^2.2.5 "@bufbuild/protoplugin": 2.2.5 "@fern-api/fai-sdk": 0.0.6-2ee1b7e28 - "@fern-api/fdr-sdk": 1.2.96-066f6aa406 + "@fern-api/fdr-sdk": 1.2.96-8e5f0d55dd "@fern-api/generator-cli": 0.9.53 "@fern-api/ui-core-utils": 0.129.4-b6c699ad2 "@fern-api/venus-api-sdk": 5.0.0 @@ -263,7 +263,7 @@ overrides: minimatch: ">=10.2.3" qs: 6.15.2 url-join: ^4.0.1 - "@fern-api/fdr-sdk": 1.2.96-066f6aa406 + "@fern-api/fdr-sdk": 1.2.96-8e5f0d55dd form-data: ^4.0.6 "@fern-api/ui-core-utils": 0.145.12-b50d999d1 vite: ^7.3.5