diff --git a/.gitignore b/.gitignore index 1ae9577d..06be1765 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,4 @@ examples/enitity/webapp/dist/ !examples/enitity/webapp/dist/ !examples/enitity/webapp/dist/index.html examples/enitity/enitity +go.work.sum diff --git a/api/keyvalue_test.go b/api/keyvalue_test.go index 63a11fe4..eb137d92 100644 --- a/api/keyvalue_test.go +++ b/api/keyvalue_test.go @@ -1,6 +1,7 @@ package api import ( + "strconv" "strings" "testing" ) @@ -188,6 +189,49 @@ func TestDescriptionList_String(t *testing.T) { } } +// TestDescriptionList_StacksLargeMaps pins the fix for the trace-output bug: +// a DescriptionList with more than 3 entries renders one pair per line instead +// of joining them onto a single multi-thousand-char line. That giant line is +// what made lipgloss right-pad every sibling tree line with thousands of +// trailing spaces (a screenful of apparent blank rows). Small lists still join +// inline. +func TestDescriptionList_StacksLargeMaps(t *testing.T) { + small := DescriptionList{Items: []KeyValuePair{ + {Key: "Name", Value: "John"}, + {Key: "Age", Value: 30}, + }} + if got := small.String(); got != "Name: John, Age: 30" { + t.Fatalf("small list must stay inline, got %q", got) + } + if strings.Contains(small.ANSI(), "\n") { + t.Fatalf("small list ANSI must stay inline, got newline in %q", small.ANSI()) + } + + items := make([]KeyValuePair, 0, 50) + for i := 0; i < 50; i++ { + items = append(items, KeyValuePair{Key: "K" + strconv.Itoa(i), Value: i}) + } + large := DescriptionList{Items: items} + + for label, out := range map[string]string{"String": large.String(), "ANSI": large.ANSI()} { + lines := strings.Split(out, "\n") + if len(lines) != len(items) { + t.Errorf("%s: large map must stack one pair per line: got %d lines for %d items", label, len(lines), len(items)) + } + widest := 0 + for _, l := range lines { + if w := len(l); w > widest { + widest = w + } + } + // A single "Kxx: yy" pair is well under 20 chars; the inline join would + // be 50× that. The widest stacked line must stay near one pair. + if widest > 30 { + t.Errorf("%s: widest stacked line %d chars — looks like it joined inline, not stacked", label, widest) + } + } +} + func TestDescriptionList_HTML_Compact(t *testing.T) { tests := []struct { name string diff --git a/api/meta.go b/api/meta.go index 9012bed9..73717802 100644 --- a/api/meta.go +++ b/api/meta.go @@ -11,9 +11,33 @@ import ( "strings" lipglosstree "github.com/charmbracelet/lipgloss/tree" + "github.com/charmbracelet/x/ansi" "github.com/samber/lo" ) +// normalizeTreeLabel drops blank lines from a tree-node label and trims +// trailing whitespace per line. lipgloss prefixes every physical line of a +// multi-line node label with the tree continuation gutter, so a blank line +// (including one that is only ANSI escape codes) renders as a bare "│"-gutter +// line. A node label is one tree row's content, not a paragraph — blank-line +// spacing that reads well in flat output is noise inside the tree. Operates on +// already-rendered text (ANSI or plain): a line counts as blank when its +// ANSI-stripped, space-trimmed form is empty. +func normalizeTreeLabel(s string) string { + if !strings.Contains(s, "\n") { + return s + } + lines := strings.Split(s, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.TrimSpace(ansi.Strip(line)) == "" { + continue + } + out = append(out, strings.TrimRight(line, " \t")) + } + return strings.Join(out, "\n") +} + // Interface types for reflection-based slice checking var ( tableProviderType = reflect.TypeOf((*TableProvider)(nil)).Elem() @@ -278,6 +302,7 @@ func (tt TextTree) buildLipglossTree(withColors bool) *lipglosstree.Tree { } else { nodeLabel = tt.Node.String() } + nodeLabel = normalizeTreeLabel(nodeLabel) } // If we have no node and only one child, return the child tree directly diff --git a/api/meta_tree_normalize_test.go b/api/meta_tree_normalize_test.go new file mode 100644 index 00000000..cb6205be --- /dev/null +++ b/api/meta_tree_normalize_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestNormalizeTreeLabel(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"single line", "hello", "hello"}, + {"clean multiline", "a\nb\nc", "a\nb\nc"}, + {"leading blank", "\nhello", "hello"}, + {"trailing blank", "hello\n", "hello"}, + {"internal blank dropped", "a\n\nb", "a\nb"}, + {"doubled internal blank dropped", "a\n\n\nb", "a\nb"}, + {"trailing spaces trimmed", "a \nb\t", "a\nb"}, + {"line of only spaces dropped", "a\n \nb", "a\nb"}, + {"ansi-only line dropped", "a\n\x1b[1;38;2;8;145;178m\x1b[0m\nb", "a\nb"}, + {"ansi content kept", "\x1b[1mTitle\x1b[0m\nbody", "\x1b[1mTitle\x1b[0m\nbody"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeTreeLabel(tt.input); got != tt.expected { + t.Errorf("normalizeTreeLabel(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +// treeNodeStub returns a Pretty() whose first node is short but whose children +// carry multi-line content with a deliberate blank-line separator — mirroring a +// gavel Test node wrapping an activity-trace detail. +type treeNodeStub struct{} + +func (treeNodeStub) Pretty() Text { + return Text{Content: "✗ trace"}. + NewLine().Add(Text{Content: "Scheme: GL Scheme"}). + NewLine().NewLine().Add(Text{Content: "Activity 123 Foo"}) +} +func (treeNodeStub) GetChildren() []TreeNode { return nil } + +var gutterOnly = regexp.MustCompile(`^[\s│├╰└─]*$`) + +// TestTextTreeRender_NoBlankGutterLines is the regression guard for stray +// "│"-gutter lines: a multi-line node label with an internal blank separator +// must not produce a bare gutter line in either the plain or ANSI tree render. +func TestTextTreeRender_NoBlankGutterLines(t *testing.T) { + root := &SimpleTreeNode{ + Label: "suite", + Children: []TreeNode{ + treeNodeStub{}, + &SimpleTreeNode{Label: "sibling"}, + }, + } + tree := NewTree[TreeNode](root) + + for _, tc := range []struct { + name string + out string + }{ + {"String", tree.String()}, + {"ANSI", tree.ANSI()}, + } { + t.Run(tc.name, func(t *testing.T) { + for i, line := range strings.Split(tc.out, "\n") { + if gutterOnly.MatchString(ansi.Strip(line)) { + t.Errorf("blank gutter line at index %d:\n%s", i, tc.out) + } + } + for _, want := range []string{"trace", "Scheme: GL Scheme", "Activity 123 Foo", "sibling"} { + if !strings.Contains(tc.out, want) { + t.Errorf("expected %q in:\n%s", want, tc.out) + } + } + }) + } +} diff --git a/api/text.go b/api/text.go index bcd03882..379ccfb0 100644 --- a/api/text.go +++ b/api/text.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/charmbracelet/x/ansi" "github.com/flanksource/clicky/api/tailwind" "github.com/samber/lo" ) @@ -548,22 +549,37 @@ func (dl DescriptionList) String() string { if len(dl.Items) == 0 { return "" } - var parts []string + parts := make([]string, 0, len(dl.Items)) for _, item := range dl.Items { parts = append(parts, item.String()) } - return strings.Join(parts, ", ") + return joinDescriptionParts(parts) } func (dl DescriptionList) ANSI() string { if len(dl.Items) == 0 { return "" } - var parts []string + parts := make([]string, 0, len(dl.Items)) for _, item := range dl.Items { parts = append(parts, item.ANSI()) } - return strings.Join(parts, ", ") + return joinDescriptionParts(parts) +} + +// joinDescriptionParts renders pre-formatted key/value parts inline (", " +// separated) for small lists that fit the terminal, and stacks them one per +// line otherwise. Inline joining is fine for a handful of pairs, but a large +// map (e.g. a 493-entry activity Math map) becomes a single multi-thousand-char +// line. When that line is the widest in a tree, lipgloss right-pads every +// sibling line to match — flooding the terminal with trailing-space "blank" +// rows. Stacking removes the giant line so nothing forces that padding. +func joinDescriptionParts(parts []string) string { + inline := strings.Join(parts, ", ") + if len(parts) <= 3 && ansi.StringWidth(inline) <= GetTerminalWidth() { + return inline + } + return strings.Join(parts, "\n") } func (dl DescriptionList) HTML() string { diff --git a/docs/cobra_walk.go b/docs/cobra_walk.go new file mode 100644 index 00000000..a784fdca --- /dev/null +++ b/docs/cobra_walk.go @@ -0,0 +1,95 @@ +package docs + +import ( + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// walkCommands invokes fn for cmd and every descendant, depth-first. +func walkCommands(cmd *cobra.Command, fn func(*cobra.Command)) { + fn(cmd) + for _, sub := range cmd.Commands() { + walkCommands(sub, fn) + } +} + +// isRunnable reports whether the command does work (has a Run/RunE) and is not +// the root. Grouping commands (no Run) are skipped in the reference body. +func isRunnable(cmd *cobra.Command) bool { + return cmd.Parent() != nil && (cmd.Run != nil || cmd.RunE != nil) +} + +// depthBelow returns how many levels cmd sits below ancestor: 0 if cmd is the +// ancestor itself, 1 for a direct child, and so on. +func depthBelow(ancestor, cmd *cobra.Command) int { + depth := 0 + for c := cmd; c != nil && c != ancestor; c = c.Parent() { + depth++ + } + return depth +} + +// commandPath returns the space-delimited path below the root, e.g. "stack create". +func commandPath(cmd *cobra.Command) string { + var parts []string + for c := cmd; c.Parent() != nil; c = c.Parent() { + parts = append([]string{c.Name()}, parts...) + } + return strings.Join(parts, " ") +} + +func flagDocs(cmd *cobra.Command) []FlagDoc { + var flags []FlagDoc + seen := map[string]bool{} + collect := func(flag *pflag.Flag) { + if flag.Hidden || seen[flag.Name] { + return + } + seen[flag.Name] = true + flags = append(flags, FlagDoc{ + Name: flag.Name, + Shorthand: flag.Shorthand, + Type: flag.Value.Type(), + Default: nonEmptyDefault(flag), + Required: flagRequired(flag), + Usage: flag.Usage, + }) + } + cmd.LocalFlags().VisitAll(collect) + cmd.InheritedFlags().VisitAll(collect) + return flags +} + +func nonEmptyDefault(flag *pflag.Flag) interface{} { + if flag.DefValue == "" || (flag.DefValue == "false" && flag.Value.Type() == "bool") { + return nil + } + return flag.DefValue +} + +func flagRequired(flag *pflag.Flag) bool { + if flag.Annotations == nil { + return false + } + _, ok := flag.Annotations["cobra_annotation_bash_completion_one_required_flag"] + return ok +} + +func title(root *cobra.Command, cfg *DocsConfig) string { + if cfg != nil && cfg.Title != "" { + return cfg.Title + } + return root.Name() +} + +func description(root *cobra.Command, cfg *DocsConfig) string { + if cfg != nil && cfg.Description != "" { + return cfg.Description + } + if root.Long != "" { + return root.Long + } + return root.Short +} diff --git a/docs/command.go b/docs/command.go new file mode 100644 index 00000000..3366efdd --- /dev/null +++ b/docs/command.go @@ -0,0 +1,140 @@ +package docs + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// NewCommand creates the docs command group with default configuration. +func NewCommand() *cobra.Command { + return NewCommandWithConfig(nil) +} + +// NewCommandWithConfig creates the docs command group. cfg supplies optional +// host defaults (title, intro, excluded commands, controller depth). +func NewCommandWithConfig(cfg *DocsConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "docs", + Short: "Generate CLI and clicky-ui documentation", + Long: `Generate a markdown CLI reference and a clicky-ui surface catalog from +this CLI's command tree. + +With --output-dir, one markdown file per high-level command controller is written +directly into that directory. Generated reference pages are refreshed on every +run; hand-editable starter pages are written once and preserved unless --force is +passed.`, + } + cmd.AddCommand(newGenerateCommand(cfg)) + return cmd +} + +func newGenerateCommand(cfg *DocsConfig) *cobra.Command { + var ( + outputFile string + outputDir string + format string + force bool + titleFlag string + descFlag string + depthFlag int + ) + + cmd := &cobra.Command{ + Use: "generate", + Short: "Generate CLI + UI docs to a file, stdout, or a directory", + Example: ` myapp docs generate # markdown to stdout + myapp docs generate -o REFERENCE.md # single file + myapp docs generate --format json # structured model as JSON + myapp docs generate --output-dir ./docs # one markdown file per controller, flat in ./docs + myapp docs generate --output-dir ./docs --depth 2 # include grandchild commands + myapp docs generate --output-dir ./docs --force # also overwrite starter pages`, + RunE: func(cmd *cobra.Command, args []string) error { + if outputFile != "" && outputDir != "" { + return fmt.Errorf("--output and --output-dir cannot be used together") + } + + effective := mergeConfig(cfg, titleFlag, descFlag, depthFlag, cmd.Flags().Changed("depth")) + + model, err := BuildModel(cmd.Root(), effective) + if err != nil { + return fmt.Errorf("failed to build docs model: %w", err) + } + + if outputDir != "" { + return runScaffold(cmd, model, outputDir, force) + } + + content, err := RenderSingleFile(model, format) + if err != nil { + return err + } + return writeSingle(cmd, content, outputFile) + }, + } + + cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write single-file output to this path (default: stdout)") + cmd.Flags().StringVar(&outputDir, "output-dir", "", "Emit one markdown file per controller directly into this directory") + cmd.Flags().StringVar(&format, "format", "markdown", "Single-file format: markdown, json, yaml") + cmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite write-once starter pages when writing to a directory") + cmd.Flags().StringVar(&titleFlag, "title", "", "Override the docs title") + cmd.Flags().StringVar(&descFlag, "description", "", "Override the docs description") + cmd.Flags().IntVar(&depthFlag, "depth", defaultDepth, "Command levels below each high-level controller to document (1=controller + direct subcommands, 0=unlimited)") + + return cmd +} + +func runScaffold(cmd *cobra.Command, model *Model, dir string, force bool) error { + result, err := Scaffold(model, dir, force) + if err != nil { + return fmt.Errorf("failed to write docs: %w", err) + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Wrote docs to %s:\n", dir) + for _, a := range result.Actions { + fmt.Fprintf(out, " %-12s %s\n", a.Status, a.Path) + } + return nil +} + +func writeSingle(cmd *cobra.Command, content, outputFile string) error { + if outputFile == "" { + fmt.Fprint(cmd.OutOrStdout(), content) + return nil + } + if err := os.MkdirAll(filepath.Dir(outputFile), 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + if err := os.WriteFile(outputFile, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", outputFile, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Documentation written to %s\n", outputFile) + return nil +} + +// mergeConfig overlays --title/--description/--depth flags onto the host +// config. depthChanged distinguishes an explicit --depth=0 (unlimited) from the +// flag's default, so the host config's Depth is only overridden when the user +// passed the flag. +func mergeConfig(base *DocsConfig, titleFlag, descFlag string, depthFlag int, depthChanged bool) *DocsConfig { + merged := DocsConfig{} + if base != nil { + merged = *base + } + if titleFlag != "" { + merged.Title = titleFlag + } + if descFlag != "" { + merged.Description = descFlag + } + if depthChanged { + if depthFlag <= 0 { + merged.Depth = unlimitedDepth + } else { + merged.Depth = depthFlag + } + } + return &merged +} diff --git a/docs/config.go b/docs/config.go new file mode 100644 index 00000000..e2154549 --- /dev/null +++ b/docs/config.go @@ -0,0 +1,57 @@ +// Package docs provides a reusable cobra command group that generates a +// markdown CLI reference and a clicky-ui surface catalog from a CLI's command +// tree, as one markdown file per high-level command controller. +// +// It mirrors the rpc.NewOpenAPICommand / mcp.NewCommand factory pattern and is +// wired into a host CLI via extensions.CobraExtensions(root).DocsCommand(). +package docs + +const ( + // defaultDepth includes each controller plus its direct subcommands. + defaultDepth = 1 + // unlimitedDepth includes a controller's entire subtree. + unlimitedDepth = -1 +) + +// DocsConfig configures the docs command group. All fields are optional; unset +// values fall back to metadata derived from the root cobra command. +type DocsConfig struct { + // Title is the docs-site title. Defaults to the root command's name. + Title string + // Description is the intro/landing-page blurb. Defaults to the root + // command's Long (or Short) text. + Description string + // Exclude lists command paths (space-delimited, e.g. "admin secret") that + // should be omitted from the generated CLI reference and UI catalog. + Exclude []string + // Depth limits how many command levels below each high-level controller + // (direct child of root) are included in that controller's page. Depth 1 + // (the default) includes the controller and its direct subcommands; depth 2 + // descends one level further. Depth 0 uses the default; negative values mean + // unlimited (the whole subtree). + Depth int +} + +// depth returns the effective controller depth, defaulting to 1. +func (c *DocsConfig) depth() int { + if c == nil || c.Depth == 0 { + return defaultDepth + } + if c.Depth < 0 { + return unlimitedDepth + } + return c.Depth +} + +// excluded reports whether the command path is in the Exclude list. +func (c *DocsConfig) excluded(cmdPath string) bool { + if c == nil { + return false + } + for _, e := range c.Exclude { + if e == cmdPath { + return true + } + } + return false +} diff --git a/docs/docs_test.go b/docs/docs_test.go new file mode 100644 index 00000000..f87ec68c --- /dev/null +++ b/docs/docs_test.go @@ -0,0 +1,459 @@ +package docs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// newTestTree builds a synthetic CLI tree exercising flags, args, examples, a +// hidden command, a grouping command, and an entity-backed list operation with +// filter/limit parameters so the UI catalog has data. +func newTestTree() *cobra.Command { + root := &cobra.Command{Use: "demo", Short: "Demo CLI", Long: "Demo CLI long description.", Version: "9.9.9"} + + run := func(cmd *cobra.Command, args []string) error { return nil } + + greet := &cobra.Command{ + Use: "greet ", + Short: "Greet someone", + Long: "Print a greeting for the named person.", + Example: "demo greet world", + Args: cobra.ExactArgs(1), + RunE: run, + } + greet.Flags().StringP("lang", "l", "en", "Language code") + greet.Flags().String("mode", "false", "Mode named false") + greet.Flags().Bool("shout", false, "Uppercase the greeting") + _ = greet.MarkFlagRequired("lang") + root.AddCommand(greet) + + hidden := &cobra.Command{Use: "secret", Short: "hidden", Hidden: true, RunE: run} + root.AddCommand(hidden) + + // Grouping command (no Run) with a runnable child — group itself is skipped. + group := &cobra.Command{Use: "stack", Short: "Manage stacks"} + list := &cobra.Command{Use: "list", Short: "List stacks", RunE: run} + list.Flags().String("env", "", "Filter by environment") + list.Flags().Int("limit", 50, "Max results") + // Annotate as a clicky-ui list surface with lookup support. + setClickyMeta(list, "stack", "list", true) + group.AddCommand(list) + + // A deeper sub-group so depth filtering has something to exclude: + // "stack secret set" sits 2 levels below the "stack" controller. + secret := &cobra.Command{Use: "secret", Short: "Manage stack secrets"} + set := &cobra.Command{Use: "set", Short: "Set a stack secret", RunE: run} + secret.AddCommand(set) + group.AddCommand(secret) + root.AddCommand(group) + + return root +} + +// setClickyMeta tags a command with the stable clicky annotation keys the +// rpc.Converter reads to derive UI surface metadata. +func setClickyMeta(cmd *cobra.Command, entity, verb string, supportsLookup bool) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations["clicky/entity-name"] = entity + cmd.Annotations["clicky/operation-verb"] = verb + if supportsLookup { + cmd.Annotations["clicky/supports-lookup"] = "true" + } +} + +func buildTestModel(t *testing.T, cfg *DocsConfig) *Model { + t.Helper() + m, err := BuildModel(newTestTree(), cfg) + if err != nil { + t.Fatalf("BuildModel: %v", err) + } + return m +} + +func findCommand(m *Model, path string) *CommandDoc { + for i := range m.Commands { + if m.Commands[i].Path == path { + return &m.Commands[i] + } + } + return nil +} + +func findSurface(m *Model, command string) *SurfaceDoc { + for i := range m.Surfaces { + if m.Surfaces[i].Command == command { + return &m.Surfaces[i] + } + } + return nil +} + +func TestBuildModelOmitsHiddenAndGroupCommands(t *testing.T) { + m := buildTestModel(t, nil) + + if findCommand(m, "secret") != nil { + t.Error("hidden command should be excluded from the reference") + } + if findCommand(m, "stack") != nil { + t.Error("grouping command (no Run) should be excluded from the reference") + } + if findCommand(m, "greet") == nil { + t.Error("runnable command 'greet' missing from the reference") + } + if findCommand(m, "stack list") == nil { + t.Error("runnable command 'stack list' missing from the reference") + } +} + +func TestBuildModelExcludeConfig(t *testing.T) { + m := buildTestModel(t, &DocsConfig{Exclude: []string{"greet"}}) + if findCommand(m, "greet") != nil { + t.Error("excluded command 'greet' should not appear") + } +} + +func TestFlagDocsCaptureTypeDefaultRequired(t *testing.T) { + m := buildTestModel(t, nil) + greet := findCommand(m, "greet") + if greet == nil { + t.Fatal("greet command missing") + } + + var lang, mode, shout *FlagDoc + for i := range greet.Flags { + switch greet.Flags[i].Name { + case "lang": + lang = &greet.Flags[i] + case "mode": + mode = &greet.Flags[i] + case "shout": + shout = &greet.Flags[i] + } + } + if lang == nil || mode == nil || shout == nil { + t.Fatalf("expected lang, mode, and shout flags, got %+v", greet.Flags) + } + if lang.Shorthand != "l" || lang.Type != "string" || lang.Default != "en" || !lang.Required { + t.Errorf("lang flag metadata wrong: %+v", lang) + } + if mode.Type != "string" || mode.Default != "false" { + t.Errorf("mode string default should keep literal false: %+v", mode) + } + if shout.Type != "bool" || shout.Required { + t.Errorf("shout flag metadata wrong: %+v", shout) + } +} + +func TestSurfaceCatalogMapping(t *testing.T) { + m := buildTestModel(t, nil) + s := findSurface(m, "stack list") + if s == nil { + t.Fatalf("expected a 'stack list' surface, got surfaces: %+v", m.Surfaces) + } + if s.Entity != "stack" || s.Verb != "list" { + t.Errorf("surface entity/verb wrong: %+v", s) + } + if s.Method != "GET" { + t.Errorf("expected GET method for list op, got %q", s.Method) + } + if !strings.HasPrefix(s.Path, "/api/v1/") { + t.Errorf("expected /api/v1 path, got %q", s.Path) + } + if !s.Lookup { + t.Error("expected SupportsLookup to be true") + } + + roles := map[string]string{} + for _, p := range s.Parameters { + roles[p.Name] = p.Role + } + if roles["limit"] != "limit" { + t.Errorf("expected 'limit' param to have role 'limit', got %q", roles["limit"]) + } + if roles["env"] != "filter" { + t.Errorf("expected 'env' param to have role 'filter', got %q", roles["env"]) + } +} + +func TestRenderCLIReferenceContent(t *testing.T) { + m := buildTestModel(t, nil) + out := RenderCLIReference(m) + + for _, want := range []string{"# CLI Reference", "## `greet`", "Greet someone", "--lang, -l", "demo greet world"} { + if !strings.Contains(out, want) { + t.Errorf("reference missing %q", want) + } + } + if strings.Contains(out, "secret") { + t.Error("reference should not mention hidden 'secret' command") + } +} + +func TestRenderUISurfacesContent(t *testing.T) { + m := buildTestModel(t, nil) + out := RenderUISurfaces(m) + + for _, want := range []string{"# UI Surface Catalog", "`stack list`", "filter (filter chip)", "limit (page size)"} { + if !strings.Contains(out, want) { + t.Errorf("surface catalog missing %q", want) + } + } +} + +func TestRenderSingleFileFormats(t *testing.T) { + m := buildTestModel(t, nil) + + md, err := RenderSingleFile(m, "markdown") + if err != nil { + t.Fatalf("markdown: %v", err) + } + if !strings.Contains(md, "# CLI Reference") || !strings.Contains(md, "# UI Surface Catalog") { + t.Error("markdown single-file should contain both sections") + } + + js, err := RenderSingleFile(m, "json") + if err != nil { + t.Fatalf("json: %v", err) + } + if !strings.Contains(js, "\"commands\"") || !strings.Contains(js, "\"surfaces\"") { + t.Errorf("json single-file should serialize the model, got: %s", js[:min(120, len(js))]) + } + + if _, err := RenderSingleFile(m, "pdf"); err == nil { + t.Error("expected unsupported --format pdf to fail loudly") + } +} + +func TestGenerateRejectsConflictingOutputFlags(t *testing.T) { + root := newTestTree() + root.AddCommand(NewCommand()) + root.SetArgs([]string{"docs", "generate", "--output", filepath.Join(t.TempDir(), "reference.md"), "--output-dir", t.TempDir()}) + if err := root.Execute(); err == nil { + t.Fatal("expected conflicting --output and --output-dir to fail") + } +} + +func TestPageRelPathIsFlat(t *testing.T) { + got := pageRelPath(Page{Key: controllerPageKey("stack")}) + if got != "stack.md" { + t.Errorf("pageRelPath = %q, want stack.md", got) + } + if got := pageRelPath(Page{Key: pageIndex}); got != "index.md" { + t.Errorf("pageRelPath = %q, want index.md", got) + } +} + +func TestScaffoldTwoTierWriteOnce(t *testing.T) { + dir := t.TempDir() + m := buildTestModel(t, nil) + + first, err := Scaffold(m, dir, false) + if err != nil { + t.Fatalf("first scaffold: %v", err) + } + for _, a := range first.Actions { + if a.Status != "written" { + t.Errorf("first run: expected all 'written', got %q for %s", a.Status, a.Path) + } + } + + // Edit a starter page and a generated page (a per-controller reference page). + starter := filepath.Join(dir, pageRelPath(Page{Key: pageGettingStarted})) + generated := filepath.Join(dir, pageRelPath(Page{Key: controllerPageKey("stack")})) + if err := os.WriteFile(starter, []byte("EDITED STARTER"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(generated, []byte("EDITED GENERATED"), 0o644); err != nil { + t.Fatal(err) + } + + second, err := Scaffold(m, dir, false) + if err != nil { + t.Fatalf("second scaffold: %v", err) + } + statusByPath := map[string]string{} + for _, a := range second.Actions { + statusByPath[a.Path] = a.Status + } + if statusByPath[pageRelPath(Page{Key: pageGettingStarted})] != "skipped" { + t.Error("starter page should be skipped on re-run without --force") + } + if statusByPath[pageRelPath(Page{Key: controllerPageKey("stack")})] != "regenerated" { + t.Error("generated page should be regenerated on re-run") + } + + if got := mustRead(t, starter); got != "EDITED STARTER" { + t.Errorf("starter page was overwritten without --force: %q", got) + } + if got := mustRead(t, generated); got == "EDITED GENERATED" { + t.Error("generated page should have been refreshed, but kept edited content") + } +} + +func TestScaffoldForceOverwritesStarter(t *testing.T) { + dir := t.TempDir() + m := buildTestModel(t, nil) + + if _, err := Scaffold(m, dir, false); err != nil { + t.Fatal(err) + } + starter := filepath.Join(dir, pageRelPath(Page{Key: pageGettingStarted})) + if err := os.WriteFile(starter, []byte("EDITED"), 0o644); err != nil { + t.Fatal(err) + } + + result, err := Scaffold(m, dir, true) + if err != nil { + t.Fatal(err) + } + for _, a := range result.Actions { + if a.Skipped { + t.Errorf("--force should not skip any page, but skipped %s", a.Path) + } + } + if got := mustRead(t, starter); got == "EDITED" { + t.Error("--force should overwrite the starter page") + } +} + +func findController(m *Model, name string) *ControllerDoc { + for i := range m.Controllers { + if m.Controllers[i].Name == name { + return &m.Controllers[i] + } + } + return nil +} + +func TestControllersGroupByTopLevelCommand(t *testing.T) { + m := buildTestModel(t, nil) + + // One controller per visible direct child of root; the hidden "secret" + // top-level command is omitted. + names := make([]string, 0, len(m.Controllers)) + for _, c := range m.Controllers { + names = append(names, c.Name) + } + want := []string{"greet", "stack"} + if strings.Join(names, ",") != strings.Join(want, ",") { + t.Fatalf("controllers = %v, want %v", names, want) + } + + greet := findController(m, "greet") + if greet == nil || len(greet.Commands) != 1 || greet.Commands[0].Path != "greet" { + t.Errorf("greet controller should hold exactly its own command, got %+v", greet) + } +} + +func TestControllerDepthDefaultExcludesGrandchildren(t *testing.T) { + // Default depth (1) documents the controller + its direct subcommands, so + // "stack list" appears but the depth-2 "stack secret set" does not. + m := buildTestModel(t, nil) + stack := findController(m, "stack") + if stack == nil { + t.Fatal("stack controller missing") + } + if findCommandIn(stack, "stack list") == nil { + t.Error("depth=1 should include direct subcommand 'stack list'") + } + if findCommandIn(stack, "stack secret set") != nil { + t.Error("depth=1 should exclude grandchild 'stack secret set'") + } +} + +func TestControllerDepthTwoIncludesGrandchildren(t *testing.T) { + m := buildTestModel(t, &DocsConfig{Depth: 2}) + stack := findController(m, "stack") + if stack == nil { + t.Fatal("stack controller missing") + } + if findCommandIn(stack, "stack secret set") == nil { + t.Error("depth=2 should include grandchild 'stack secret set'") + } +} + +func TestControllerDepthUnlimited(t *testing.T) { + m := buildTestModel(t, &DocsConfig{Depth: unlimitedDepth}) + stack := findController(m, "stack") + if findCommandIn(stack, "stack secret set") == nil { + t.Error("unlimited depth should include all descendants") + } +} + +func findCommandIn(c *ControllerDoc, path string) *CommandDoc { + for i := range c.Commands { + if c.Commands[i].Path == path { + return &c.Commands[i] + } + } + return nil +} + +func TestRenderControllerProducesOneDocument(t *testing.T) { + m := buildTestModel(t, &DocsConfig{Depth: 2}) + stack := findController(m, "stack") + out := RenderController(*stack) + + for _, want := range []string{"# stack", "Manage stacks", "## `stack list`", "## `stack secret set`"} { + if !strings.Contains(out, want) { + t.Errorf("controller page missing %q in:\n%s", want, out) + } + } +} + +func TestScaffoldWritesControllerFilesDirectlyInOutputDir(t *testing.T) { + dir := t.TempDir() + m := buildTestModel(t, nil) + + if _, err := Scaffold(m, dir, false); err != nil { + t.Fatalf("scaffold: %v", err) + } + // One file per controller, directly under the output dir — no commands/ or + // src/content/docs/ subtree, and no frontmatter. + for _, name := range []string{"greet", "stack"} { + body := mustRead(t, filepath.Join(dir, name+".md")) + if strings.HasPrefix(body, "---") { + t.Errorf("%s.md should have no frontmatter, got:\n%s", name, body[:min(40, len(body))]) + } + } + if _, err := os.Stat(filepath.Join(dir, "commands")); !os.IsNotExist(err) { + t.Error("should not create a commands/ subdir") + } + if _, err := os.Stat(filepath.Join(dir, "src")); !os.IsNotExist(err) { + t.Error("should not create a src/content/docs subtree") + } +} + +func TestScaffoldFailsOnPathCollision(t *testing.T) { + pages := []Page{ + {Key: pageIndex}, + {Key: controllerPageKey("index")}, // a command literally named "index" + } + if err := assertNoPathCollisions(pages); err == nil { + t.Error("expected a collision error when a controller's filename matches a starter page") + } +} + +func TestScaffoldRejectsPathTraversalPageKey(t *testing.T) { + pages := []Page{ + {Key: "../outside"}, + } + if err := assertNoPathCollisions(pages); err == nil { + t.Fatal("expected path traversal page key to fail") + } +} + +func mustRead(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(b) +} diff --git a/docs/markdown.go b/docs/markdown.go new file mode 100644 index 00000000..88f2c70e --- /dev/null +++ b/docs/markdown.go @@ -0,0 +1,119 @@ +package docs + +import ( + "fmt" + "strings" +) + +// mdBuilder is a small structured-markdown writer. It exists because +// api.Text.Markdown() renders inline styling but not document structure +// (headings, tables, fenced code blocks), which is what a docs page needs. +type mdBuilder struct { + b strings.Builder +} + +func (m *mdBuilder) heading(level int, text string) *mdBuilder { + if level < 1 { + level = 1 + } + m.b.WriteString(strings.Repeat("#", level)) + m.b.WriteByte(' ') + m.b.WriteString(text) + m.b.WriteString("\n\n") + return m +} + +func (m *mdBuilder) para(text string) *mdBuilder { + if text == "" { + return m + } + m.b.WriteString(text) + m.b.WriteString("\n\n") + return m +} + +func (m *mdBuilder) line(text string) *mdBuilder { + m.b.WriteString(text) + m.b.WriteByte('\n') + return m +} + +func (m *mdBuilder) blank() *mdBuilder { + m.b.WriteByte('\n') + return m +} + +func (m *mdBuilder) codeBlock(lang, body string) *mdBuilder { + m.b.WriteString("```") + m.b.WriteString(lang) + m.b.WriteByte('\n') + m.b.WriteString(body) + if !strings.HasSuffix(body, "\n") { + m.b.WriteByte('\n') + } + m.b.WriteString("```\n\n") + return m +} + +// table writes a GitHub-flavored markdown table. Rows with fewer cells than +// headers are padded; pipes in cells are escaped. +func (m *mdBuilder) table(headers []string, rows [][]string) *mdBuilder { + if len(headers) == 0 { + return m + } + m.b.WriteString("| " + strings.Join(escapeCells(headers), " | ") + " |\n") + sep := make([]string, len(headers)) + for i := range sep { + sep[i] = "---" + } + m.b.WriteString("| " + strings.Join(sep, " | ") + " |\n") + for _, row := range rows { + cells := make([]string, len(headers)) + for i := range headers { + if i < len(row) { + cells[i] = row[i] + } + } + m.b.WriteString("| " + strings.Join(escapeCells(cells), " | ") + " |\n") + } + m.b.WriteByte('\n') + return m +} + +func (m *mdBuilder) String() string { + return m.b.String() +} + +func escapeCells(cells []string) []string { + out := make([]string, len(cells)) + for i, c := range cells { + c = strings.ReplaceAll(c, "|", "\\|") + out[i] = strings.ReplaceAll(c, "\n", " ") + } + return out +} + +func code(s string) string { + if s == "" { + return "" + } + return "`" + s + "`" +} + +func boolMark(b bool) string { + if b { + return "✓" + } + return "" +} + +func defaultStr(v interface{}) string { + if v == nil { + return "" + } + s := fmt.Sprintf("%v", v) + if s == "" { + return "" + } + return code(s) +} diff --git a/docs/model.go b/docs/model.go new file mode 100644 index 00000000..6ff73aac --- /dev/null +++ b/docs/model.go @@ -0,0 +1,199 @@ +package docs + +import ( + "sort" + + "github.com/flanksource/clicky/rpc" + "github.com/spf13/cobra" +) + +// Model is the structured docs model assembled from a CLI's command tree. It is +// the single source for every output: CLI reference, UI catalog, and the +// json/yaml structured dumps. +type Model struct { + Title string `json:"title" yaml:"title"` + Description string `json:"description" yaml:"description"` + Version string `json:"version" yaml:"version"` + Controllers []ControllerDoc `json:"controllers" yaml:"controllers"` + Commands []CommandDoc `json:"commands" yaml:"commands"` + Surfaces []SurfaceDoc `json:"surfaces" yaml:"surfaces"` +} + +// ControllerDoc groups the commands of one high-level command controller — a +// direct child of the root — into a single documentation unit (one page). A +// top-level leaf command (no subcommands) is its own controller. +type ControllerDoc struct { + Name string `json:"name" yaml:"name"` // controller name, e.g. "stack" + Short string `json:"short" yaml:"short"` // controller one-line summary + Long string `json:"long" yaml:"long"` // controller full description + Commands []CommandDoc `json:"commands" yaml:"commands"` // commands within the depth limit +} + +// CommandDoc documents a single runnable command for the CLI reference. +type CommandDoc struct { + Path string `json:"path" yaml:"path"` // e.g. "stack create" + Use string `json:"use" yaml:"use"` // raw cobra Use string + Short string `json:"short" yaml:"short"` // one-line summary + Long string `json:"long" yaml:"long"` // full description + Example string `json:"example" yaml:"example"` // example block + Aliases []string `json:"aliases" yaml:"aliases"` // command aliases + Flags []FlagDoc `json:"flags" yaml:"flags"` +} + +// FlagDoc documents a single flag. +type FlagDoc struct { + Name string `json:"name" yaml:"name"` + Shorthand string `json:"shorthand" yaml:"shorthand"` + Type string `json:"type" yaml:"type"` + Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` + Required bool `json:"required" yaml:"required"` + Usage string `json:"usage" yaml:"usage"` +} + +// SurfaceDoc documents a clicky-ui operation surface: the entity/verb mapping, +// its UI-role parameters, lookup support, and the HTTP endpoint + CLI command +// the UI uses to invoke it. +type SurfaceDoc struct { + Command string `json:"command" yaml:"command"` // CLI command path + Entity string `json:"entity" yaml:"entity"` // entity name + Verb string `json:"verb" yaml:"verb"` // list/get/create/update/delete/action + Method string `json:"method" yaml:"method"` // HTTP method + Path string `json:"path" yaml:"path"` // /api/v1/... + Lookup bool `json:"lookup" yaml:"lookup"` // SupportsLookup + Parameters []ParameterDoc `json:"parameters" yaml:"parameters"` +} + +// ParameterDoc documents a single UI parameter and its widget role. +type ParameterDoc struct { + Name string `json:"name" yaml:"name"` + Type string `json:"type" yaml:"type"` + Role string `json:"role,omitempty" yaml:"role,omitempty"` // filter/limit/offset/time-from/time-to + Required bool `json:"required" yaml:"required"` + Default interface{} `json:"default,omitempty" yaml:"default,omitempty"` + In string `json:"in" yaml:"in"` // query/path/body +} + +// BuildModel converts the root cobra command into a Model using rpc.Converter +// for operation metadata (paths, verbs, parameter roles) and walking the +// command tree directly for the CLI reference. +func BuildModel(root *cobra.Command, cfg *DocsConfig) (*Model, error) { + m := &Model{ + Title: title(root, cfg), + Description: description(root, cfg), + Version: root.Version, + } + + converter := rpc.NewConverter(rpc.DefaultConfig()) + service, err := converter.ConvertCommandTree(root) + if err != nil { + return nil, err + } + + m.Controllers = buildControllers(root, cfg) + m.Commands = flattenControllers(m.Controllers) + m.Surfaces = buildSurfaceDocs(service, cfg) + return m, nil +} + +// buildControllers groups the command tree into one ControllerDoc per direct +// child of the root. Each controller collects its own runnable commands down to +// the configured depth (relative to the controller). Hidden, grouping, and +// excluded commands are omitted; a controller with no documented commands is +// dropped. +func buildControllers(root *cobra.Command, cfg *DocsConfig) []ControllerDoc { + maxDepth := cfg.depth() + var controllers []ControllerDoc + + for _, top := range root.Commands() { + if top.Hidden { + continue + } + ctrl := ControllerDoc{Name: top.Name(), Short: top.Short, Long: top.Long} + walkCommands(top, func(cmd *cobra.Command) { + if !isRunnable(cmd) || cmd.Hidden { + return + } + if maxDepth != unlimitedDepth && depthBelow(top, cmd) > maxDepth { + return + } + path := commandPath(cmd) + if cfg.excluded(path) { + return + } + ctrl.Commands = append(ctrl.Commands, commandDoc(cmd, path)) + }) + if len(ctrl.Commands) == 0 { + continue + } + sort.SliceStable(ctrl.Commands, func(i, j int) bool { + return ctrl.Commands[i].Path < ctrl.Commands[j].Path + }) + controllers = append(controllers, ctrl) + } + + sort.SliceStable(controllers, func(i, j int) bool { + return controllers[i].Name < controllers[j].Name + }) + return controllers +} + +func commandDoc(cmd *cobra.Command, path string) CommandDoc { + return CommandDoc{ + Path: path, + Use: cmd.Use, + Short: cmd.Short, + Long: cmd.Long, + Example: cmd.Example, + Aliases: cmd.Aliases, + Flags: flagDocs(cmd), + } +} + +// flattenControllers returns every controller's commands as one sorted list, +// preserving the flat CLI-reference view alongside the grouped one. +func flattenControllers(controllers []ControllerDoc) []CommandDoc { + var docs []CommandDoc + for _, c := range controllers { + docs = append(docs, c.Commands...) + } + sort.SliceStable(docs, func(i, j int) bool { return docs[i].Path < docs[j].Path }) + return docs +} + +func buildSurfaceDocs(service *rpc.RPCService, cfg *DocsConfig) []SurfaceDoc { + var docs []SurfaceDoc + for _, op := range service.Operations { + if op.Clicky == nil || op.Clicky.Entity == "" { + continue // not a clicky-ui surface + } + if cfg.excluded(op.Name) { + continue + } + sd := SurfaceDoc{ + Command: op.Name, + Entity: op.Clicky.Entity, + Verb: op.Clicky.Verb, + Method: op.Method, + Path: op.Path, + Lookup: op.Clicky.SupportsLookup, + } + for _, p := range op.Parameters { + sd.Parameters = append(sd.Parameters, ParameterDoc{ + Name: p.Name, + Type: p.Type, + Role: rpc.ParamRole(op, p), + Required: p.Required, + Default: p.Default, + In: p.In, + }) + } + docs = append(docs, sd) + } + sort.SliceStable(docs, func(i, j int) bool { + if docs[i].Entity != docs[j].Entity { + return docs[i].Entity < docs[j].Entity + } + return docs[i].Command < docs[j].Command + }) + return docs +} diff --git a/docs/pages.go b/docs/pages.go new file mode 100644 index 00000000..312f35fa --- /dev/null +++ b/docs/pages.go @@ -0,0 +1,156 @@ +package docs + +import "fmt" + +// Page keys. +const ( + pageIndex = "index" + pageUISurfaces = "ui-surfaces" + pageGettingStarted = "getting-started" + pageIntegration = "clicky-ui-integration" +) + +// controllerPageKey is the page key (and flat filename stem) for a controller's +// reference page. A controller's key is just its command name so the file lands +// as .md directly under the output directory. +func controllerPageKey(name string) string { return name } + +// buildPages assembles the full set of docs-site pages from the model. Two +// tiers: Generated pages (one per controller, ui-surfaces) are rewritten every +// run; starter pages (index, getting-started, clicky-ui-integration) are +// write-once. Each high-level controller gets its own reference page. +func buildPages(m *Model) []Page { + pages := []Page{ + { + Key: pageIndex, + Title: m.Title, + Description: firstLine(m.Description), + Body: renderIndex(m), + Generated: false, + }, + { + Key: pageGettingStarted, + Title: "Getting Started", + Description: "Install and run " + m.Title + ".", + Body: renderGettingStarted(m), + Generated: false, + }, + } + + for _, ctrl := range m.Controllers { + pages = append(pages, Page{ + Key: controllerPageKey(ctrl.Name), + Title: ctrl.Name, + Description: controllerDescription(ctrl), + Body: RenderController(ctrl), + Generated: true, + }) + } + + pages = append(pages, + Page{ + Key: pageUISurfaces, + Title: "UI Surface Catalog", + Description: "clicky-ui surfaces, operations, and parameter widget roles.", + Body: RenderUISurfaces(m), + Generated: true, + }, + Page{ + Key: pageIntegration, + Title: "clicky-ui Integration", + Description: "Wire " + m.Title + " into the clicky-ui web explorer.", + Body: renderIntegration(m), + Generated: false, + }, + ) + return pages +} + +func controllerDescription(ctrl ControllerDoc) string { + if ctrl.Short != "" { + return firstLine(ctrl.Short) + } + return fmt.Sprintf("Commands under `%s`.", ctrl.Name) +} + +func renderIndex(m *Model) string { + md := &mdBuilder{} + md.heading(1, m.Title) + md.para(m.Description) + md.heading(2, "Documentation") + md.line("- [Getting Started](./getting-started) — install and run.") + md.line("- [UI Surface Catalog](./ui-surfaces) — what the web explorer exposes.") + md.line("- [clicky-ui Integration](./clicky-ui-integration) — serve the web UI.") + md.blank() + + md.heading(2, "Command Reference") + for _, ctrl := range m.Controllers { + line := fmt.Sprintf("- [%s](./%s)", ctrl.Name, controllerPageKey(ctrl.Name)) + if ctrl.Short != "" { + line += " — " + firstLine(ctrl.Short) + } + md.line(line) + } + md.blank() + return md.String() +} + +func renderGettingStarted(m *Model) string { + md := &mdBuilder{} + md.heading(1, "Getting Started") + md.para("This page is a starter you can edit; it is not overwritten by regenerating the docs unless you pass `--force`.") + md.heading(2, "Install") + md.codeBlock("sh", fmt.Sprintf("# install the %s CLI\n# (replace with your distribution method)", m.Title)) + md.heading(2, "Run") + md.codeBlock("sh", m.Title+" --help") + return md.String() +} + +func renderIntegration(m *Model) string { + md := &mdBuilder{} + md.heading(1, "clicky-ui Integration") + md.para("This page is a starter you can edit; regenerating the docs does not overwrite it unless you pass `--force`.") + md.para("clicky-ui is a React component library that renders a metadata-driven explorer over a CLI's operations. It discovers operations from the OpenAPI spec and invokes them over HTTP.") + + md.heading(2, "Endpoints") + md.table( + []string{"Path", "Purpose"}, + [][]string{ + {code("/api/openapi.json"), "OpenAPI spec (with x-clicky extensions) the UI reads to build the catalog."}, + {code("/api/v1/..."), "Executor endpoints the UI calls to run operations."}, + {code("/"), "The embedded React explorer."}, + }, + ) + + md.heading(2, "Serving the UI") + md.para("Wire the executor-backed OpenAPI server and the embedded explorer from the same command registrations:") + md.codeBlock("go", integrationSnippet) + + md.heading(2, "Response envelope") + md.para("Executor responses are wrapped in an `ExecutionResponse` envelope carrying `success`, `stdout`/`stderr`, `exit_code`, the structured `output`, and a `cli` field with the equivalent command for reproduction.") + + md.heading(2, "Surfaces") + md.para("See the [UI Surface Catalog](./ui-surfaces) for the operations this CLI exposes, their endpoints, and parameter widget roles.") + return md.String() +} + +const integrationSnippet = `serveConfig := &rpc.ServeConfig{ + Host: host, Port: port, + Title: "` + "" + `", Version: "1.0.0", + Executor: &rpc.ExecutorConfig{Enabled: true, PathPrefix: "/api/v1"}, +} +server := rpc.NewSwaggerServer(serveConfig, rootCmd, openAPIConfig) + +mux := http.NewServeMux() +server.RegisterRoutes(mux) // /api/openapi.json + /api/v1/... +mux.Handle("/", uiHandler) // embedded clicky-ui explorer +http.ListenAndServe(addr, mux)` + +func firstLine(s string) string { + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + return s[:i] + } + } + return s +} diff --git a/docs/provider.go b/docs/provider.go new file mode 100644 index 00000000..e616bb6b --- /dev/null +++ b/docs/provider.go @@ -0,0 +1,16 @@ +package docs + +// Page is a logical docs page produced by the generator. Each page is written +// as a plain markdown file (.md) directly under the output directory. +type Page struct { + Key string // logical key + filename stem: "index", "stack", "ui-surfaces", ... + Title string + Description string + Body string // markdown body + Generated bool // regenerated every run (true) vs write-once starter (false) +} + +// pageRelPath returns the page's filename relative to the output directory. +// Controller keys are bare command names (see controllerPageKey), so files land +// flat directly under --output-dir. +func pageRelPath(p Page) string { return p.Key + ".md" } diff --git a/docs/reference.go b/docs/reference.go new file mode 100644 index 00000000..2f8f5849 --- /dev/null +++ b/docs/reference.go @@ -0,0 +1,90 @@ +package docs + +import "fmt" + +// RenderCLIReference produces the markdown CLI reference body (no frontmatter). +// It documents every controller in order, each as a section. +func RenderCLIReference(m *Model) string { + md := &mdBuilder{} + md.heading(1, "CLI Reference") + md.para(fmt.Sprintf("Command reference for `%s`, generated from the live command tree.", m.Title)) + + for _, ctrl := range m.Controllers { + md.heading(2, code(ctrl.Name)) + if ctrl.Short != "" { + md.para(ctrl.Short) + } + if ctrl.Long != "" && ctrl.Long != ctrl.Short { + md.para(ctrl.Long) + } + for _, c := range ctrl.Commands { + renderCommand(md, c, 3) + } + } + return md.String() +} + +// RenderController produces the markdown body for a single controller's page +// (no frontmatter): the controller summary followed by each of its commands. +func RenderController(ctrl ControllerDoc) string { + md := &mdBuilder{} + md.heading(1, ctrl.Name) + if ctrl.Short != "" { + md.para(ctrl.Short) + } + if ctrl.Long != "" && ctrl.Long != ctrl.Short { + md.para(ctrl.Long) + } + for _, c := range ctrl.Commands { + renderCommand(md, c, 2) + } + return md.String() +} + +func renderCommand(md *mdBuilder, c CommandDoc, level int) { + md.heading(level, code(c.Path)) + if c.Short != "" { + md.para(c.Short) + } + if c.Long != "" && c.Long != c.Short { + md.para(c.Long) + } + + md.line("**Usage:**").blank() + md.codeBlock("", c.Use) + + if len(c.Aliases) > 0 { + md.para("**Aliases:** " + code(joinCode(c.Aliases))) + } + + if len(c.Flags) > 0 { + md.line("**Flags:**").blank() + rows := make([][]string, 0, len(c.Flags)) + for _, f := range c.Flags { + name := "--" + f.Name + if f.Shorthand != "" { + name += ", -" + f.Shorthand + } + rows = append(rows, []string{ + code(name), code(f.Type), defaultStr(f.Default), boolMark(f.Required), f.Usage, + }) + } + md.table([]string{"Flag", "Type", "Default", "Required", "Description"}, rows) + } + + if c.Example != "" { + md.line("**Examples:**").blank() + md.codeBlock("sh", c.Example) + } +} + +func joinCode(items []string) string { + out := "" + for i, s := range items { + if i > 0 { + out += ", " + } + out += s + } + return out +} diff --git a/docs/render.go b/docs/render.go new file mode 100644 index 00000000..f640c747 --- /dev/null +++ b/docs/render.go @@ -0,0 +1,22 @@ +package docs + +import ( + "fmt" + + "github.com/flanksource/clicky/formatters" +) + +// RenderSingleFile renders the whole model to one document in the requested +// format. "markdown" (default) concatenates the CLI reference and UI catalog; +// "json"/"yaml" emit the structured Model via clicky's FormatManager. Other +// formats fail loudly — prose markdown is not converted to html/pdf here. +func RenderSingleFile(m *Model, format string) (string, error) { + switch format { + case "", "markdown", "md": + return RenderCLIReference(m) + "\n" + RenderUISurfaces(m), nil + case "json", "yaml", "yml": + return formatters.NewFormatManager().Format(format, m) + default: + return "", fmt.Errorf("unsupported --format %q (supported: markdown, json, yaml)", format) + } +} diff --git a/docs/scaffold.go b/docs/scaffold.go new file mode 100644 index 00000000..9be9d725 --- /dev/null +++ b/docs/scaffold.go @@ -0,0 +1,105 @@ +package docs + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// WriteAction records what happened to one file during scaffolding. +type WriteAction struct { + Path string // path relative to the output directory + Status string // "written", "regenerated", or "skipped" + Skipped bool +} + +// ScaffoldResult is the outcome of writing a docs site, for reporting. +type ScaffoldResult struct { + Dir string + Actions []WriteAction +} + +// Scaffold writes the model's pages as flat markdown files directly under dir, +// applying the two-tier policy: Generated pages are always (re)written; starter +// pages are written only when absent, unless force is set. Returns a per-file +// report so the caller can show exactly what was written vs. skipped (no silent +// skips). +func Scaffold(m *Model, dir string, force bool) (*ScaffoldResult, error) { + result := &ScaffoldResult{Dir: dir} + + pages := buildPages(m) + if err := assertNoPathCollisions(pages); err != nil { + return nil, err + } + + for _, page := range pages { + rel := pageRelPath(page) + abs := filepath.Join(dir, rel) + + exists, err := fileExists(abs) + if err != nil { + return nil, err + } + + // Write-once policy for starter pages. + if !page.Generated && exists && !force { + result.Actions = append(result.Actions, WriteAction{Path: rel, Status: "skipped", Skipped: true}) + continue + } + + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(abs, []byte(page.Body), 0o644); err != nil { + return nil, err + } + + status := "written" + if page.Generated && exists { + status = "regenerated" + } + result.Actions = append(result.Actions, WriteAction{Path: rel, Status: status}) + } + + return result, nil +} + +// assertNoPathCollisions fails loudly when two pages map to the same on-disk +// path — e.g. a controller named "index" would otherwise clobber the landing +// page. Better a clear error than a silent overwrite. +func assertNoPathCollisions(pages []Page) error { + seen := map[string]string{} + for _, p := range pages { + if err := validatePageKey(p.Key); err != nil { + return err + } + rel := pageRelPath(p) + if other, ok := seen[rel]; ok { + return fmt.Errorf("docs page collision: %q and %q both map to %s (rename the conflicting command)", other, p.Key, rel) + } + seen[rel] = p.Key + } + return nil +} + +func validatePageKey(key string) error { + if key == "" || key == "." || key == ".." { + return fmt.Errorf("invalid docs page key %q", key) + } + if strings.ContainsAny(key, `/\`) || filepath.Clean(key) != key { + return fmt.Errorf("invalid docs page key %q: page keys must be flat filenames", key) + } + return nil +} + +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/docs/surfaces.go b/docs/surfaces.go new file mode 100644 index 00000000..29d0571a --- /dev/null +++ b/docs/surfaces.go @@ -0,0 +1,89 @@ +package docs + +import ( + "fmt" + "strings" +) + +// RenderUISurfaces produces the markdown clicky-ui surface catalog body (no +// frontmatter). It documents, per operation: the surface entity + verb, the +// HTTP endpoint + CLI command the UI invokes, lookup support, and each +// parameter's UI-widget role. +func RenderUISurfaces(m *Model) string { + md := &mdBuilder{} + md.heading(1, "UI Surface Catalog") + md.para("Operations exposed by the clicky-ui metadata-driven explorer. Each surface maps an entity verb to an HTTP endpoint the UI calls, and lists the parameters that render as filter, pagination, and time-range widgets.") + + if len(m.Surfaces) == 0 { + md.para("_No clicky-ui surfaces are registered for this CLI._") + return md.String() + } + + for _, s := range m.Surfaces { + renderSurface(md, s) + } + return md.String() +} + +func renderSurface(md *mdBuilder, s SurfaceDoc) { + heading := fmt.Sprintf("%s — %s", code(s.Command), strings.ToUpper(verbOrDash(s.Verb))) + md.heading(2, heading) + + md.table( + []string{"Entity", "Verb", "HTTP", "Endpoint", "CLI", "Lookup"}, + [][]string{{ + code(s.Entity), + verbOrDash(s.Verb), + code(s.Method), + code(s.Path), + code(s.Command), + boolMark(s.Lookup), + }}, + ) + + if len(s.Parameters) == 0 { + md.para("_No parameters._") + return + } + + md.line("**Parameters:**").blank() + rows := make([][]string, 0, len(s.Parameters)) + for _, p := range s.Parameters { + rows = append(rows, []string{ + code(p.Name), + code(p.Type), + code(p.In), + roleLabel(p.Role), + boolMark(p.Required), + defaultStr(p.Default), + }) + } + md.table([]string{"Name", "Type", "In", "UI Role", "Required", "Default"}, rows) +} + +func verbOrDash(verb string) string { + if verb == "" { + return "—" + } + return verb +} + +// roleLabel describes the UI widget a parameter role drives. +func roleLabel(role string) string { + switch role { + case "filter": + return "filter (filter chip)" + case "limit": + return "limit (page size)" + case "offset": + return "offset (page)" + case "time-from": + return "time-from (range start)" + case "time-to": + return "time-to (range end)" + case "": + return "" + default: + return role + } +} diff --git a/entity.go b/entity.go index dc2da53c..fa44882b 100644 --- a/entity.go +++ b/entity.go @@ -149,6 +149,14 @@ type Filter[ListOpts any] interface { Options(opts ListOpts) map[string]api.Textable } +// TypedFilter is an OPTIONAL extension a Filter may implement when the +// underlying flag type is intentionally broader than the UI control type. For +// example, date-math flags often remain strings at the CLI layer while the web +// lookup response should still advertise a from/to date-range control. +type TypedFilter[ListOpts any] interface { + LookupType() string +} + // SearchableFilter is an OPTIONAL extension a Filter may implement when its // option set is too large to enumerate in one shot (e.g. a SQL DISTINCT column // with thousands of values). The lookup handler type-asserts to it; filters @@ -229,6 +237,12 @@ func (l liftedFilter[Outer, Inner]) Lookup(opts *Outer) (map[string]api.Textable func (l liftedFilter[Outer, Inner]) Options(opts Outer) map[string]api.Textable { return l.inner.Options(*l.project(&opts)) } +func (l liftedFilter[Outer, Inner]) LookupType() string { + if typed, ok := l.inner.(TypedFilter[Inner]); ok { + return typed.LookupType() + } + return "" +} // OptionsWithQuery forwards to the inner filter when it is searchable, so a // lifted DistinctColumnFilter keeps its head/search behaviour. When the inner @@ -1503,6 +1517,11 @@ func resolveLookup[T any]( } for _, filter := range filters { meta := lookupMetadata[filter.Key()] + if typed, ok := filter.(TypedFilter[T]); ok { + if lookupType := typed.LookupType(); lookupType != "" { + meta.Type = lookupType + } + } entry := entityLookupFilter{ Label: filter.Label(), Selected: toClickyNodeMap(selected[filter.Key()]), diff --git a/entity_filters_test.go b/entity_filters_test.go index f59b3136..bc50e864 100644 --- a/entity_filters_test.go +++ b/entity_filters_test.go @@ -28,6 +28,7 @@ type entityFilterTestOpts struct { entityFilterEmbeddedOpts Owner string `flag:"owner"` Status string `flag:"status"` + Amount string `flag:"amount"` Active bool `flag:"active"` Since time.Time `flag:"since"` From time.Time `flag:"from"` @@ -38,6 +39,10 @@ type entityMultiFilterTestOpts struct { Status MultiFilter `flag:"status"` } +type entityFilterOuterOpts struct { + entityFilterTestOpts +} + type ownerEntityFilter struct{} func (ownerEntityFilter) Key() string { return "owner" } @@ -127,6 +132,21 @@ func (tagsEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textab } } +type amountEntityFilter struct{} + +func (amountEntityFilter) Key() string { return "amount" } +func (amountEntityFilter) Label() string { return "Amount" } + +func (amountEntityFilter) Lookup(opts *entityFilterTestOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (amountEntityFilter) Options(opts entityFilterTestOpts) map[string]api.Textable { + return nil +} + +func (amountEntityFilter) LookupType() string { return "number" } + type activeEntityFilter struct{} func (activeEntityFilter) Key() string { return "active" } @@ -237,6 +257,31 @@ func TestLookupMetadataDetectsMultiFilter(t *testing.T) { } } +func TestLiftedFiltersPreserveTypedLookupOverrides(t *testing.T) { + filters := LiftFilters[entityFilterOuterOpts, entityFilterTestOpts]( + []Filter[entityFilterTestOpts]{ + amountEntityFilter{}, + activeEntityFilter{}, + }, + func(opts *entityFilterOuterOpts) *entityFilterTestOpts { + return &opts.entityFilterTestOpts + }, + ) + + lookupAny, err := buildLookupFunc(filters)(nil, nil) + if err != nil { + t.Fatalf("lookup func returned error: %v", err) + } + lookup := lookupAny.(entityLookupResponse) + + if lookup.Filters["amount"].Type != "number" { + t.Fatalf("expected lifted typed filter to preserve type override, got %#v", lookup.Filters["amount"]) + } + if lookup.Filters["active"].Type != "bool" { + t.Fatalf("expected lifted untyped filter to keep inferred bool type, got %#v", lookup.Filters["active"]) + } +} + func TestEntityListFiltersResolveAndLookup(t *testing.T) { resetEntityRegistry(t) defer resetEntityRegistry(t) @@ -249,6 +294,7 @@ func TestEntityListFiltersResolveAndLookup(t *testing.T) { ownerEntityFilter{}, statusEntityFilter{}, tagsEntityFilter{}, + amountEntityFilter{}, activeEntityFilter{}, sinceEntityFilter{}, fromEntityFilter{}, @@ -330,6 +376,10 @@ func TestEntityListFiltersResolveAndLookup(t *testing.T) { t.Fatalf("expected tags lookup to advertise multi=true, got %#v", lookup.Filters["tags"]) } + if lookup.Filters["amount"].Type != "number" { + t.Fatalf("expected amount lookup type=number from filter override, got %#v", lookup.Filters["amount"]) + } + if lookup.Filters["active"].Type != "bool" { t.Fatalf("expected active lookup type=bool, got %#v", lookup.Filters["active"]) } diff --git a/examples/enitity/main.go b/examples/enitity/main.go index 202c08a8..0525fab4 100644 --- a/examples/enitity/main.go +++ b/examples/enitity/main.go @@ -18,6 +18,7 @@ import ( "github.com/flanksource/clicky" "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/docs" "github.com/flanksource/clicky/extensions" "github.com/flanksource/clicky/formatters" "github.com/flanksource/clicky/mcp" @@ -1173,6 +1174,12 @@ and the executor-backed OpenAPI serve mode from the same registrations.`, }, }) + extensions.CobraExtensions(rootCmd).DocsCommandWithConfig(&docs.DocsConfig{ + Title: "Clicky Entity Example", + Description: "Entity example app with a CLI reference and clicky-ui surface catalog.", + Exclude: []string{"serve-ui"}, + }) + rootCmd.AddCommand(newServeUICommand()) // Expose every entity command as an MCP tool so the same registrations diff --git a/extensions/cobra.go b/extensions/cobra.go index 17fcb58e..8305162c 100644 --- a/extensions/cobra.go +++ b/extensions/cobra.go @@ -1,6 +1,7 @@ package extensions import ( + "github.com/flanksource/clicky/docs" "github.com/flanksource/clicky/mcp" "github.com/flanksource/clicky/rpc" "github.com/spf13/cobra" @@ -42,10 +43,23 @@ func (c *CobraExtension) MCPCommandWithConfig(config *mcp.Config) *CobraExtensio return c } -// All adds both OpenAPI and MCP commands with default configuration +// DocsCommand adds the docs generation command group to the CLI +// Usage: extensions.CobraExtensions(rootCmd).DocsCommand() +func (c *CobraExtension) DocsCommand() *CobraExtension { + c.rootCmd.AddCommand(docs.NewCommand()) + return c +} + +// DocsCommandWithConfig adds the docs command group with custom configuration +func (c *CobraExtension) DocsCommandWithConfig(config *docs.DocsConfig) *CobraExtension { + c.rootCmd.AddCommand(docs.NewCommandWithConfig(config)) + return c +} + +// All adds OpenAPI, MCP, and docs commands with default configuration // Usage: extensions.CobraExtensions(rootCmd).All() func (c *CobraExtension) All() *CobraExtension { - return c.OpenAPICommand().MCPCommand() + return c.OpenAPICommand().MCPCommand().DocsCommand() } // ServeCommand adds OpenAPI serve functionality for Swagger UI documentation @@ -61,7 +75,7 @@ func (c *CobraExtension) ServeCommandWithConfig(config *rpc.OpenAPIConfig) *Cobr return c } -// AllWithConfig adds both OpenAPI and MCP commands with custom configurations +// AllWithConfig adds OpenAPI, MCP, and docs commands with custom configurations func (c *CobraExtension) AllWithConfig(openAPIConfig *rpc.OpenAPIConfig, mcpConfig *mcp.Config) *CobraExtension { - return c.OpenAPICommandWithConfig(openAPIConfig).MCPCommandWithConfig(mcpConfig) + return c.OpenAPICommandWithConfig(openAPIConfig).MCPCommandWithConfig(mcpConfig).DocsCommand() } diff --git a/formatters/tree_formatter.go b/formatters/tree_formatter.go index 3bf01367..988c3e18 100644 --- a/formatters/tree_formatter.go +++ b/formatters/tree_formatter.go @@ -135,6 +135,26 @@ func buildNoTreeDataMessage(data *api.PrettyData) string { return msg.String() } +// normalizeTreeLabel removes trailing whitespace per line and drops every blank +// line from a node label so multi-line labels don't render stray "│"-gutter +// blank lines. lipgloss prefixes every physical line of a multi-line label with +// the continuation gutter, so a blank line surfaces as a bare gutter line. A +// node label is a single tree row's content, not a paragraph — blank-line +// spacing that reads well in flat output is noise inside the tree. +func normalizeTreeLabel(s string) string { + if !strings.ContainsAny(s, "\n \t") { + return s + } + lines := strings.Split(s, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + if line = strings.TrimRight(line, " \t"); line != "" { + out = append(out, line) + } + } + return strings.Join(out, "\n") +} + // buildLipglossTree builds a lipgloss tree structure from a TreeNode func (f *TreeFormatter) buildLipglossTree(node api.TreeNode, depth int) *tree.Tree { if node == nil { @@ -144,17 +164,18 @@ func (f *TreeFormatter) buildLipglossTree(node api.TreeNode, depth int) *tree.Tr // All TreeNodes implement Pretty(), so use it for formatting prettyText := node.Pretty() - // Build the node label with styling + // Build the node label with styling. Normalize the plain text before any + // lipgloss styling so trimming isn't fighting ANSI reset sequences and + // multi-line labels don't render stray "│"-gutter blank lines. var nodeLabel string if f.NoColor { - nodeLabel = prettyText.String() + nodeLabel = normalizeTreeLabel(prettyText.String()) } else { - // Apply lipgloss styling if we have a style + content := normalizeTreeLabel(prettyText.Content) if prettyText.Style != "" { - style := parseTailwindToLipgloss(prettyText.Style) - nodeLabel = style.Render(prettyText.Content) + nodeLabel = parseTailwindToLipgloss(prettyText.Style).Render(content) } else { - nodeLabel = prettyText.Content + nodeLabel = content } } diff --git a/formatters/tree_formatter_test.go b/formatters/tree_formatter_test.go new file mode 100644 index 00000000..bdbf5734 --- /dev/null +++ b/formatters/tree_formatter_test.go @@ -0,0 +1,101 @@ +package formatters + +import ( + "regexp" + "strings" + "testing" + + "github.com/flanksource/clicky/api" +) + +func TestNormalizeTreeLabel(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty", "", ""}, + {"single line", "hello", "hello"}, + {"already clean multiline", "a\nb\nc", "a\nb\nc"}, + {"leading blank", "\nhello", "hello"}, + {"trailing blank", "hello\n", "hello"}, + {"leading and trailing blank", "\n\nhello\n\n", "hello"}, + {"doubled internal blank dropped", "a\n\n\nb", "a\nb"}, + {"single internal blank dropped", "a\n\nb", "a\nb"}, + {"trailing spaces trimmed", "a \nb\t", "a\nb"}, + {"line of only spaces dropped", "a\n \nb", "a\nb"}, + {"leading line of only spaces dropped", " \na", "a"}, + {"mixed: trailing space + blanks all dropped", "root \n\n\nchild line\n \n\ntail ", "root\nchild line\ntail"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeTreeLabel(tt.input) + if got != tt.expected { + t.Errorf("normalizeTreeLabel(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestNormalizeTreeLabelIdempotent(t *testing.T) { + in := "root\n\nchild line\n\ntail" + once := normalizeTreeLabel(in) + twice := normalizeTreeLabel(once) + if once != twice { + t.Errorf("normalizeTreeLabel not idempotent: once=%q twice=%q", once, twice) + } + if strings.Contains(once, "\n\n") { + t.Errorf("normalizeTreeLabel left an internal blank line: %q", once) + } +} + +// blankGutterLine matches a rendered tree line carrying only tree-connector +// glyphs (│ ├ ╰ └ ─) and whitespace — a line where the node label contributed +// no visible content. lipgloss right-pads lines, so trailing space is ignored. +var blankGutterLine = regexp.MustCompile(`^[\s│├╰└─]*$`) + +// TestFormatTreeDropsBlankGutterLines renders a tree whose first (non-last) +// child carries a multi-line label with leading, trailing, and internal blank +// lines plus trailing whitespace. A non-last child uses the "│" continuation +// gutter, so any surviving blank physical line shows up as a "│"-only line. +// Policy: every blank line in a node label is dropped, so the rendered tree has +// no blank gutter lines at all; content lines stay in order. +func TestFormatTreeDropsBlankGutterLines(t *testing.T) { + child := &api.SimpleTreeNode{ + Label: "\nroot \n\n\nchild line\n \n\ntail \n", + } + sibling := &api.SimpleTreeNode{Label: "sibling"} + parent := &api.SimpleTreeNode{ + Label: "parent", + Children: []api.TreeNode{child, sibling}, + } + + f := NewTreeFormatter(api.DefaultTheme(), true, nil) + out := f.FormatTreeFromRoot(parent) + lines := strings.Split(out, "\n") + + for i, line := range lines { + if blankGutterLine.MatchString(line) { + t.Errorf("blank gutter line at index %d (should be dropped):\n%s", i, out) + } + } + + // The first child's label must sit on the branch line — leading blank gone. + if rootLine := lines[1]; !strings.Contains(rootLine, "root") { + t.Errorf("expected first child line to carry 'root', got %q in:\n%s", rootLine, out) + } + + for _, want := range []string{"parent", "root", "child line", "tail", "sibling"} { + if !strings.Contains(out, want) { + t.Errorf("expected output to contain %q, got:\n%s", want, out) + } + } + + rootIdx := strings.Index(out, "root") + childIdx := strings.Index(out, "child line") + tailIdx := strings.Index(out, "tail") + if !(rootIdx < childIdx && childIdx < tailIdx) { + t.Errorf("content lines out of order: root=%d child=%d tail=%d in:\n%s", rootIdx, childIdx, tailIdx, out) + } +} diff --git a/rpc/openapi.go b/rpc/openapi.go index e9d058dc..ac4025df 100644 --- a/rpc/openapi.go +++ b/rpc/openapi.go @@ -335,8 +335,6 @@ func (g *OpenAPIGenerator) convertOperationToOpenAPI(op RPCOperation) OpenAPIOpe } // Convert parameters - supportsLookup := op.Clicky != nil && op.Clicky.SupportsLookup - isListOp := op.Clicky != nil && op.Clicky.Verb == "list" for _, param := range op.Parameters { openAPIParam := OpenAPIParameter{ Name: param.Name, @@ -349,7 +347,7 @@ func (g *OpenAPIGenerator) convertOperationToOpenAPI(op RPCOperation) OpenAPIOpe Default: param.Default, }), } - if role := paramRole(param, supportsLookup, isListOp); role != "" { + if role := ParamRole(op, param); role != "" { openAPIParam.Clicky = &ClickyParameterMeta{Role: role} } openAPIOp.Parameters = append(openAPIOp.Parameters, openAPIParam) @@ -399,6 +397,15 @@ func (g *OpenAPIGenerator) convertOperationToOpenAPI(op RPCOperation) OpenAPIOpe return openAPIOp } +// ParamRole classifies a parameter within its operation, deriving the +// supportsLookup/isListOp signals from the operation's Clicky metadata. Returns +// the UI role ("limit", "offset", "time-from", "time-to", "filter") or "". +func ParamRole(op RPCOperation, param RPCParameter) string { + supportsLookup := op.Clicky != nil && op.Clicky.SupportsLookup + isListOp := op.Clicky != nil && op.Clicky.Verb == "list" + return paramRole(param, supportsLookup, isListOp) +} + // paramRole classifies a parameter so the UI can route it to the right widget // (pagination footer, time-range picker, filter pill) instead of falling back // to a generic text input. Returns "" when the parameter has no special role. diff --git a/task/manager.go b/task/manager.go index 389c616c..2ccf04c8 100644 --- a/task/manager.go +++ b/task/manager.go @@ -53,6 +53,11 @@ type Manager struct { // next ClearLines to cover any log lines that landed between ticks. logSerializer *logSerializingWriter + // liveRenderer, when set, replaces the default task-tree content for live + // ticks and the final summary. Installed via SetLiveRenderer; guarded by + // mu (see get/setLiveRenderer). + liveRenderer LiveRenderer + // Priority queue for task scheduling taskQueue *collections.Queue[*Task] workers []*worker diff --git a/task/manager_lifecycle.go b/task/manager_lifecycle.go index a50aaf26..8df700f6 100644 --- a/task/manager_lifecycle.go +++ b/task/manager_lifecycle.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/flanksource/clicky/api" "github.com/flanksource/commons/logger" "golang.org/x/term" ) @@ -165,7 +166,12 @@ func (tm *Manager) renderFinal() { copy(taskSnapshot, tm.tasks) tm.mu.RUnlock() - rendered := tm.prettyFromTasks(taskSnapshot) + var rendered api.Text + if r := tm.getLiveRenderer(); r != nil { + rendered = r.RenderFinal(taskSnapshot) + } else { + rendered = tm.prettyFromTasks(taskSnapshot) + } // Write through renderer.Output (original stderr captured at init) so // the final summary joins the live render stream and doesn't land in // the StartCapturingOutput pipe — otherwise buffered content flushed diff --git a/task/render.go b/task/render.go index 7a6462cb..edf4cdf1 100644 --- a/task/render.go +++ b/task/render.go @@ -10,6 +10,14 @@ import ( // PlainRender outputs the current task statuses in plain text without any interactive / ANSI / console features func (tm *Manager) PlainRender() { + // A custom LiveRenderer owns the whole block; render it once per tick + // rather than the per-task dirty loop so its layout (e.g. a status table) + // stays coherent in non-interactive / piped output. + if r := tm.getLiveRenderer(); r != nil { + tm.plainRenderLive(r) + return + } + tm.mu.RLock() defer tm.mu.RUnlock() if len(tm.tasks) == 0 { @@ -42,23 +50,35 @@ func (tm *Manager) PlainRender() { } } +// plainRenderLive prints a custom LiveRenderer's block for one non-interactive +// tick, guarded by bufferMutex so a concurrent log-serializer write can't split +// the block. +func (tm *Manager) plainRenderLive(r LiveRenderer) { + rendered := r.RenderLive(tm.snapshotTasks()) + output := tm.renderer.Output() + tm.bufferMutex.Lock() + defer tm.bufferMutex.Unlock() + if tm.noColor.Load() { + fmt.Fprintf(output, "%s\n", rendered.String()) + } else { + fmt.Fprintf(output, "%s\n", rendered.ANSI()) + } +} + func (tm *Manager) Pretty() api.Text { if tm == nil { return api.Text{} } + return tm.prettyFromTasks(tm.snapshotTasks()) +} - tm.mu.RLock() - if len(tm.tasks) == 0 { - tm.mu.RUnlock() - return api.Text{} +// renderLiveText produces the content for one live tick: the installed +// LiveRenderer's output when set, otherwise the default task-tree formatting. +func (tm *Manager) renderLiveText() api.Text { + if r := tm.getLiveRenderer(); r != nil { + return r.RenderLive(tm.snapshotTasks()) } - - // Create snapshot to avoid holding lock during formatting - taskSnapshot := make([]*Task, len(tm.tasks)) - copy(taskSnapshot, tm.tasks) - tm.mu.RUnlock() - - return tm.prettyFromTasks(taskSnapshot) + return tm.Pretty() } // prettyFromTasks formats a snapshot of tasks without needing locks. @@ -161,7 +181,7 @@ func (tm *Manager) prettyFromTasks(tasks []*Task) api.Text { // interactiveRender renders tasks in-place using ANSI clear lines. // Returns the number of lines rendered for the next cycle's ClearLines call. func (tm *Manager) interactiveRender(lastLines int) int { - rendered := tm.Pretty() + rendered := tm.renderLiveText() var out string if tm.noColor.Load() { out = rendered.String() diff --git a/task/render_hook.go b/task/render_hook.go new file mode 100644 index 00000000..4878a0db --- /dev/null +++ b/task/render_hook.go @@ -0,0 +1,49 @@ +package task + +import "github.com/flanksource/clicky/api" + +// LiveRenderer replaces the default task-tree rendering with caller-supplied +// content while keeping clicky's terminal ownership: the render loop still owns +// the TTY, ClearLines line accounting, and the logger serializer that prevents +// concurrent log lines from corrupting the in-place frame. Only the rendered +// content (an api.Text) is swapped — a caller that wants its own block (a status +// table, a custom dashboard) renders it through clicky instead of hand-rolling +// ANSI redraws that collide with logger output. +// +// Install with SetLiveRenderer; remove by passing nil. Both methods receive a +// snapshot of the current tasks and must not mutate them. +type LiveRenderer interface { + // RenderLive returns the content for one live frame — a 250ms interactive + // tick or a single non-interactive (PlainRender) flush. + RenderLive(tasks []*Task) api.Text + // RenderFinal returns the content drawn once after all tasks complete. + RenderFinal(tasks []*Task) api.Text +} + +// SetLiveRenderer installs a custom renderer on the global manager, or removes +// it when r is nil. It is process-global like SetNoRender; install it for the +// duration of one command and restore (SetLiveRenderer(nil)) afterwards. +func SetLiveRenderer(r LiveRenderer) { + global.setLiveRenderer(r) +} + +func (tm *Manager) setLiveRenderer(r LiveRenderer) { + tm.mu.Lock() + defer tm.mu.Unlock() + tm.liveRenderer = r +} + +func (tm *Manager) getLiveRenderer() LiveRenderer { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.liveRenderer +} + +// snapshotTasks returns a lock-free copy of the manager's tasks for rendering. +func (tm *Manager) snapshotTasks() []*Task { + tm.mu.RLock() + defer tm.mu.RUnlock() + snapshot := make([]*Task, len(tm.tasks)) + copy(snapshot, tm.tasks) + return snapshot +} diff --git a/task/render_hook_test.go b/task/render_hook_test.go new file mode 100644 index 00000000..8355191f --- /dev/null +++ b/task/render_hook_test.go @@ -0,0 +1,206 @@ +package task + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/flanksource/commons/logger" + + "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/text" +) + +// fakeLiveRenderer returns fixed content so tests can assert the manager draws +// the caller's block instead of the default task tree. It records the task +// count it was handed so tests can confirm the snapshot is passed through. +type fakeLiveRenderer struct { + live string + final string + liveTasks int +} + +func (f *fakeLiveRenderer) RenderLive(tasks []*Task) api.Text { + f.liveTasks = len(tasks) + return api.Text{Content: f.live} +} + +func (f *fakeLiveRenderer) RenderFinal(tasks []*Task) api.Text { + return api.Text{Content: f.final} +} + +func addCompletedTask(tm *Manager, name string) { + task := tm.newTask(name) + task.SetStatus(StatusSuccess) + task.completed.Store(true) + task.dirty.Store(true) + tm.mu.Lock() + tm.tasks = append(tm.tasks, task) + tm.mu.Unlock() +} + +// When a LiveRenderer is installed, the live-tick content comes from it and the +// default task-tree formatting (the task names) is absent. +func TestLiveRenderer_ReplacesLiveContent(t *testing.T) { + tm := newTestManager(1) + t.Cleanup(func() { close(tm.shutdown) }) + + addCompletedTask(tm, "internal-task-name") + + fake := &fakeLiveRenderer{live: "CUSTOM STATUS TABLE"} + tm.setLiveRenderer(fake) + + rendered := text.StripANSI(tm.renderLiveText().String()) + + if !strings.Contains(rendered, "CUSTOM STATUS TABLE") { + t.Errorf("expected custom renderer content, got:\n%s", rendered) + } + if strings.Contains(rendered, "internal-task-name") { + t.Errorf("default task-tree content leaked through custom renderer:\n%s", rendered) + } + if fake.liveTasks != 1 { + t.Errorf("renderer should receive the 1-task snapshot, got %d", fake.liveTasks) + } +} + +// A nil renderer leaves the default task-tree output untouched (regression +// guard: the hook must be opt-in). +func TestLiveRenderer_NilFallsBackToDefault(t *testing.T) { + tm := newTestManager(1) + t.Cleanup(func() { close(tm.shutdown) }) + + addCompletedTask(tm, "default-task-name") + + rendered := text.StripANSI(tm.renderLiveText().String()) + if !strings.Contains(rendered, "default-task-name") { + t.Errorf("nil renderer should produce default task-tree output, got:\n%s", rendered) + } +} + +// renderFinal must use RenderFinal, not RenderLive or the default tree. +func TestLiveRenderer_FinalUsesRenderFinal(t *testing.T) { + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + // Redirect stderr before creating the manager so its lipgloss renderer + // (constructed from os.Stderr in newManager) writes into the pipe. + os.Stderr = w + capture := &bytes.Buffer{} + done := make(chan struct{}) + go func() { + _, _ = io.Copy(capture, r) + close(done) + }() + + tm := newTestManager(1) + t.Cleanup(func() { close(tm.shutdown) }) + + addCompletedTask(tm, "task-x") + tm.setLiveRenderer(&fakeLiveRenderer{live: "LIVE-ONLY", final: "FINAL-SUMMARY"}) + + tm.renderFinal() + + os.Stderr = originalStderr + _ = w.Close() + <-done + _ = r.Close() + + out := text.StripANSI(capture.String()) + if !strings.Contains(out, "FINAL-SUMMARY") { + t.Errorf("renderFinal should emit RenderFinal content, got:\n%s", out) + } + if strings.Contains(out, "LIVE-ONLY") { + t.Errorf("renderFinal must not emit RenderLive content, got:\n%s", out) + } +} + +// PlainRender (non-interactive) emits the whole custom block once per tick, +// not the per-task dirty loop. +func TestLiveRenderer_PlainRenderEmitsWholeBlock(t *testing.T) { + originalStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + // Redirect stderr before creating the manager so its lipgloss renderer + // writes into the pipe. + os.Stderr = w + capture := &bytes.Buffer{} + done := make(chan struct{}) + go func() { + _, _ = io.Copy(capture, r) + close(done) + }() + + tm := newTestManager(1) + t.Cleanup(func() { close(tm.shutdown) }) + + // Three tasks; only one dirty. The default PlainRender would print just the + // dirty one. The custom renderer renders one fixed block regardless. + for i := 0; i < 3; i++ { + task := tm.newTask(fmt.Sprintf("file-%d", i)) + task.SetStatus(StatusSuccess) + task.completed.Store(true) + task.dirty.Store(i == 1) + tm.mu.Lock() + tm.tasks = append(tm.tasks, task) + tm.mu.Unlock() + } + tm.setLiveRenderer(&fakeLiveRenderer{live: "WHOLE BLOCK"}) + + tm.PlainRender() + + os.Stderr = originalStderr + _ = w.Close() + <-done + _ = r.Close() + + out := text.StripANSI(capture.String()) + if !strings.Contains(out, "WHOLE BLOCK") { + t.Errorf("PlainRender should emit the custom block, got:\n%s", out) + } + if strings.Contains(out, "file-0") || strings.Contains(out, "file-1") { + t.Errorf("custom renderer should replace per-task lines, got:\n%s", out) + } +} + +// SetLiveRenderer is process-global and round-trips: install then clear. +func TestSetLiveRenderer_RoundTrip(t *testing.T) { + t.Cleanup(func() { SetLiveRenderer(nil) }) + + fake := &fakeLiveRenderer{live: "x"} + SetLiveRenderer(fake) + if global.getLiveRenderer() != fake { + t.Fatalf("SetLiveRenderer did not install the renderer") + } + SetLiveRenderer(nil) + if global.getLiveRenderer() != nil { + t.Fatalf("SetLiveRenderer(nil) did not clear the renderer") + } +} + +// With a custom renderer active, a logger line emitted between ticks is still +// accounted for by the serializer, so the next ClearLines widens to cover it. +// This is the property that fixes the original corruption: the hook must not +// bypass the line-accounting that keeps the redraw in sync. +func TestLiveRenderer_LoggerLinesStillAccounted(t *testing.T) { + tm := &Manager{} + tm.installLogSerializer() + t.Cleanup(tm.uninstallLogSerializer) + + tm.setLiveRenderer(&fakeLiveRenderer{live: "block"}) + + logger.Infof("between-ticks log line") + + // The serializer counted the newline the logger emitted; a tick reads it + // via TakeLinesWritten to widen ClearLines. Without accounting this is 0 + // and the next frame would stack. + if got := tm.logSerializer.TakeLinesWritten(); got < 1 { + t.Errorf("expected logger line to be counted for ClearLines widening, got %d", got) + } +}