man.zig

52.7 KB · raw

1// man.zig — `nina man`, an interactive, colorful pager for nina's built-in docs.2//3// The pager is inline (no alternate screen), like search.zig's widget, so the4// terminal stays where it was when the user quits. It draws a header and footer,5// paginates the rendered content vertically, and accepts vim/less-style keys.6//7// Content lives in `MAN_PAGE` below — a static tree of chapters that mirrors8// the docs site. Everything renders through `renderLine`, which translates a9// typed block into an ANSI-styled string. No external files; the manpage is10// part of the binary.11//12// Keys: j/k or arrows scroll one line · space / b page down/up · g / G top /13// bottom · n / p (or → / ←) next / previous section · ? help · q or esc quit.14// Colors match the website palette: pink #AA6A52, gold #AD8A50, mint #708881,15// lavender #7A8162 on bg-surface #EEE6DB / bg-deep #CDBFAE.16const std = @import("std");17const output = @import("output.zig");18const rawterm = @import("rawterm.zig");19const build_options = @import("build_options");2021const PINK = "\x1b[38;2;170;106;82m"; // --pink (#AA6A52)22const GOLD = "\x1b[38;2;173;138;80m"; // --gold (#AD8A50)23const MINT = "\x1b[38;2;112;136;129m"; // --mint (#708881)24const LAV = "\x1b[38;2;122;129;98m"; // --lavender (#7A8162)25// Body text. The website's --text-mid (#665D55) is calibrated for a cream26// background; on a dark terminal it sinks in and becomes unreadable. Lifted to27// a warm light tone so paragraphs, callout bodies, and flag descriptions read28// against the terminal's default (usually dark) background.29const TEXT = "\x1b[38;2;217;207;195m"; // body text, lightened for dark terminals30const TEXT_DIM = "\x1b[38;2;136;124;113m"; // --text-dim (#887C71)31const BG_HI = "\x1b[48;2;238;230;219m"; // --bg-surface (#EEE6DB)32const BG_BAR = "\x1b[48;2;205;191;174m"; // --bg-deep (#CDBFAE)33const RESET = "\x1b[0m";34const BOLD = "\x1b[1m";35const DIM = "\x1b[2m";36const INV = "\x1b[7m";3738// Returns a code (e.g. color) gated by output.colorEnabled() so piped NO_COLOR39// output stays plain — matches the rest of nina's output module.40fn c(code: []const u8) []const u8 {41    return if (output.colorEnabled()) code else "";42}4344// --- Block types ---4546const Block = union(enum) {47    chapter: []const u8, // small caps divider like "GETTING STARTED"48    title: []const u8, // section title49    badge: []const u8, // accent badge on the right of the title row50    accent: Accent, // which color paints the title + badge border51    para: []const u8, // wrapped paragraph52    cmd_sig: []const u8, // `nina install <pkg>` — green-on-dark-ish signature line53    cmd_block: []const u8, // monospace example block54    flags_row: FlagsRow, // two-column row55    callout: Callout, // boxed tip with kaomoji56    kaomoji_card: KaomojiCard,57    blank,5859    const Accent = enum { gold, mint, pink, lav };6061    const FlagsRow = struct {62        flag: []const u8,63        desc: []const u8,64    };6566    const Callout = struct {67        face: []const u8, // kaomoji68        body: []const u8,69        color: Accent,70    };7172    const KaomojiCard = struct {73        face: []const u8,74        name: []const u8,75        desc: []const u8,76        when: []const u8,77        color: Accent,78    };79};8081const Section = struct {82    id: []const u8, // for `nina man <id>` direct jumps83    title: []const u8,84    accent: Block.Accent,85    blocks: []const Block,86};8788// --- Content tree ---89//90// Mirrors ninawebsite/src/pages/docs.astro sections. Tweak here, rebuild, and91// the binary's manpage reflects the change. Keep IDs stable — `nina man <id>`92// depends on them.9394const MAN_PAGE = [_]Section{95    .{96        .id = "intro",97        .title = "what is nina?",98        .accent = .gold,99        .blocks = &[_]Block{100            .{ .para = "nina is a NixOS Intuitive Navigation Assistant — a gentle command-line tool that wraps the full NixOS workflow in one quiet, consistent voice." },101            .{ .para = "she handles package search, rebuilds, flakes, services, generation history, and remote machines — all without dropping you into a different screen, switching tools, or making you memorise long nixos-rebuild incantations." },102            .{ .para = "written in zig. zero runtime dependencies. a single binary. runs the same on any NixOS machine." },103            .{ .para = "the goal is simple: NixOS should feel like home. nina is one step toward that." },104            .{105                .callout = .{106                    .face = "(っ˘ω˘ς)",107                    .color = .gold,108                    .body = "nina stays inline at your prompt. search results, install confirmations, diff previews — everything comes to you. you never leave the shell you're already in.",109                },110            },111        },112    },113    .{114        .id = "install",115        .title = "installation",116        .accent = .gold,117        .blocks = &[_]Block{118            .{ .para = "nina is a nix flake. two ways to install:" },119            .{ .para = "both methods require nix-command and flakes to be enabled. add this to ~/.config/nix/nix.conf (or /etc/nix/nix.conf on NixOS):" },120            .{ .cmd_block = "experimental-features = nix-command flakes" },121            .{ .para = "or run `nina setup` after installing — she will add the line for you." },122            .{ .para = "direct url (simplest):" },123            .{ .cmd_block = "$ nix profile add 'https://kepr.uk/nina/archive/HEAD.tar.gz#nina'" },124            .{ .para = "or build from source with koh:" },125            .{ .cmd_block = "$ koh steal kepr.uk/nina && koh build" },126            .{127                .callout = .{128                    .face = "(•̀ᴗ•́)و",129                    .color = .mint,130                    .body = "after installing, run `nina setup` — it makes sure the nix features nina needs are turned on, and on NixOS walks you through the one config line.",131                },132            },133        },134    },135    .{136        .id = "setup",137        .title = "nina setup",138        .accent = .mint,139        .blocks = &[_]Block{140            .{ .para = "`nina setup` is your onboarding step. it makes sure nix's experimental features are enabled, which is all nina needs to search and install packages. if they're already on, it prints `all set` and exits." },141            .{ .cmd_block = "$ nina setup" },142            .{ .para = "on NixOS, nina offers to add this line to /etc/nixos/configuration.nix (keeping a `.nina-bak` backup):" },143            .{ .cmd_block = "nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];" },144            .{ .para = "press y and nina appends it for you. press n and nina shows you the line to paste. either way it ends with one step:" },145            .{ .cmd_block = "$ sudo nixos-rebuild switch" },146            .{ .para = "everywhere else, nina writes `experimental-features = nix-command flakes` to ~/.config/nix/nix.conf. no sudo needed." },147        },148    },149    .{150        .id = "first-run",151        .title = "first run",152        .accent = .gold,153        .blocks = &[_]Block{154            .{ .para = "just type `nina help` at the prompt. she will show you every command she knows. (っ˘ω˘ς)" },155            .{ .para = "then try `nina hello` to see the machines nina has configured, or jump straight to `nina search` to find a package." },156            .{ .para = "from here, search for a package, apply a change, or wander through the tips section to see what is possible." },157        },158    },159    .{160        .id = "search",161        .title = "search & discovery",162        .accent = .mint,163        .blocks = &[_]Block{164            .{ .para = "inline search that never takes over your screen. results appear right below the prompt, and you navigate them with the keyboard. press esc any time to go back to what you were doing." },165            .{ .para = "search nixpkgs for packages in an 18-line inline widget — no fullscreen takeover, no context switch." },166            .{ .cmd_block = "$ nina search ripgrep\n:: search nixpkgs ripgrep  3 results\n\n   ripgrep       14.1.0   recursively search directories\n   ripgrep-all   0.10.0   ripgrep, but also search in PDFs\n   ripgrep-fixed  0.1.0    ripgrep with fixed-string matching\n\n   enter install  ·  tab info  ·  [up/down] nav  ·  esc cancel" },167            .{ .para = "search nixpkgs options the same way:" },168            .{ .cmd_block = "$ nina option logind\n:: search options logind  N results" },169            .{170                .callout = .{171                    .face = "( •̀ω•́ )✧",172                    .color = .mint,173                    .body = "keys: [up/down] move through results · enter install the highlighted package · tab toggle package info · esc cancel",174                },175            },176        },177    },178    .{179        .id = "packages",180        .title = "packages",181        .accent = .lav,182        .blocks = &[_]Block{183            .{ .para = "install packages into your profile with a single command. nina searches nixpkgs, shows you what it found, and asks how you want it." },184            .{ .para = "[i] profile install — runs `nix profile install nixpkgs#attr`. available immediately, no rebuild needed." },185            .{ .para = "[s] system install — opens configuration.nix in your editor at the systemPackages line. add the package, save, and nina asks whether to apply." },186            .{ .para = "[t] try now — drops you into a `nix shell` with the package available. gone when you exit." },187            .{ .cmd_block = "$ nina install neovim\n:: neovim  0.10.4\n\n   [i] profile install    instant  no rebuild\n   [s] system install     opens editor  requires apply\n   [t] try now            exits when done\n\n   > i\n:: installing neovim  profile\n   -> nix profile install nixpkgs#neovim\n:: neovim installed  available now" },188            .{ .para = "explain why a package is installed:" },189            .{ .cmd_block = "$ nina why ripgrep\n:: why ripgrep  kyoshi\n\n   ripgrep is a direct profile package" },190            .{ .para = "`nina pin <input> <rev>` reads the input source from `flake.lock` and runs `nix flake lock --override-input` with a commit-specific flake ref. `nina unpin <input>` releases it again by running `nix flake update` on that input." },191        },192    },193    .{194        .id = "system",195        .title = "system changes",196        .accent = .pink,197        .blocks = &[_]Block{198            .{ .para = "preview, apply, and roll back changes to your nixos system. nina shows you exactly what will change before it happens, and arms a rollback the moment things go sideways." },199            .{ .para = "compare two generations using `nix store diff-closures`:" },200            .{ .cmd_block = "$ nina diff\n:: gen 348 -> gen 349  kyoshi\n\n   + neovim   0.10.4\n   ^ ripgrep  14.0.3 -> 14.1.1" },201            .{ .para = "validate your configuration with `nixos-rebuild build`. catches syntax errors and missing references before they reach a running system." },202            .{ .para = "rebuild and switch to the new generation:" },203            .{ .cmd_block = "$ nina apply\n:: rebuilding kyoshi...\n:: generation 349  [4.1s]" },204            .{ .para = "roll back one generation. to jump to a specific generation, use `nina go <n>`." },205            .{ .cmd_block = "$ nina back\n:: rolling back  kyoshi  gen 349 -> gen 348\n:: done  gen 348" },206            .{207                .flags_row = .{ .flag = "--on <machine>", .desc = "rebuild a remote machine over SSH" },208            },209            .{210                .flags_row = .{ .flag = "--dry", .desc = "dry-activate only — build without switching" },211            },212            .{213                .flags_row = .{ .flag = "--check", .desc = "validate config only, same as `nina check`" },214            },215            .{216                .callout = .{217                    .face = "ε-(´∀`; )",218                    .color = .pink,219                    .body = "generations are immutable snapshots. `nina back` always has somewhere to go, and `nina go <n>` can reach any point in your history.",220                },221            },222        },223    },224    .{225        .id = "flakes",226        .title = "flakes & dev shells",227        .accent = .mint,228        .blocks = &[_]Block{229            .{ .para = "inspect flake outputs, step into dev shells, build specific outputs, and manage the flake lifecycle — all with the same quiet vocabulary." },230            .{ .para = "show what the current directory's flake exposes — packages, apps, dev shells, nixos configurations." },231            .{ .cmd_block = "$ nina flake show\n:: .\n\n   packages     x86_64-linux   nina\n   apps         x86_64-linux   nina\n   devShells    x86_64-linux   default" },232            .{ .para = "run `nix flake update`. pass a specific input name to update only that one. prints a confirmation when `flake.lock` is written." },233            .{ .para = "enter the flake's dev shell with `nix develop`. type `exit` to return. `develop run <cmd>` runs a single command without an interactive shell." },234            .{ .para = "build a flake output with `nix build`. result is linked as `./result`. run a package without installing it — bare names are resolved as `nixpkgs#<pkg>`." },235        },236    },237    .{238        .id = "inspection",239        .title = "inspection & care",240        .accent = .lav,241        .blocks = &[_]Block{242            .{ .para = "check on the health of your system, follow service logs, inspect the store, and reclaim disk space — without jumping between different tools." },243            .{ .para = "show the current generation and reachability of the target machine:" },244            .{ .cmd_block = "$ nina status\n:: status\n\n   kyoshi   gen 349   1h ago   10.2 GB\n            hm  gen 2   2h ago" },245            .{ .para = "run a health diagnostic. checks the nix daemon, config syntax, channels, and home manager." },246            .{ .cmd_block = "$ nina doctor\n:: diagnosing  kyoshi\n\n   nix daemon      ok\n   config syntax   ok\n   channel         ok\n   home manager    standalone  gen 2\n\n:: all good" },247            .{ .para = "manage systemd services. `service logs <name> -f` follows the journal live. `stop` and `disable` prompt for confirmation when `confirm = true`. all service commands accept `--user` to target user-managed services." },248            .{ .para = "inspect and maintain the nix store. `store info` shows total size, live paths, and reclaimable space. `store gc` runs garbage collection. `store path <attr>` evaluates a store path for a nixpkgs attribute." },249            .{ .para = "remove old generations and run garbage collection. keeps the number of generations set by `generations` in your config (default: 5). `--all` removes all old generations regardless." },250            .{ .cmd_block = "$ nina clean\n:: cleaning  kyoshi  keeping 5 of 14\n   continue? [Y/n]  y\n:: freed 4.2 GB" },251        },252    },253    .{254        .id = "generations",255        .title = "generation history",256        .accent = .gold,257        .blocks = &[_]Block{258            .{ .para = "your system's whole story is saved in generations. browse them, compare them, travel back to any of them. nothing is gone until you clean it up." },259            .{ .cmd_block = "$ nina history\n:: generations  kyoshi\n\n   349   2026-05-30   09:32:01   current\n   348   2026-05-29   14:22:44\n   347   2026-05-25   11:05:33" },260            .{ .para = "switch to any specific generation by number using `nix-env --switch-generation`. use this when `nina back` is not far enough." },261            .{ .para = "show nina's operation log from ~/.nina.log. displays the last 50 entries by default. use `--last N` to show more or fewer." },262            .{263                .callout = .{264                    .face = "(´▽`)",265                    .color = .gold,266                    .body = "generations are your safety net. apply freely, knowing you can always go back. `nina history` shows the whole story. `nina go` takes you there.",267                },268            },269        },270    },271    .{272        .id = "home",273        .title = "home manager",274        .accent = .lav,275        .blocks = &[_]Block{276            .{ .para = "nina manages your home manager configuration with the same vocabulary as your system. all `nina home` commands work with standalone home manager and NixOS module setups. nina detects which mode you're using automatically." },277            .{ .para = "bootstrap home manager for the first time. runs `nix run home-manager/<branch> -- init`, which creates ~/.config/home-manager/home.nix with a starter configuration. passing `--switch` activates the configuration immediately." },278            .{ .cmd_block = "$ nina home init --switch\n(っ˘ω˘ς) :: setting up home manager  ~/.config/home-manager\n   -> will activate after init\n\n(˘ᵕ˘) :: home manager ready" },279            .{ .para = "apply your home manager configuration. runs `home-manager switch` under the hood. `--dry` builds without activating." },280            .{ .para = "roll back to the previous home manager generation. list home generations. show what changed between the last two with `home diff`. validate without applying with `home check`. open `home.nix` with `home edit`." },281            .{ .cmd_block = "$ nina home packages\n\n:: home packages  kyoshi\n\n   hello    2.12.1\n   jq       1.7.1" },282            .{283                .callout = .{284                    .face = "(っ˘ω˘ς)",285                    .color = .lav,286                    .body = "the same apply → diff → back pattern works for your home config, just as it does for your system. nina uses the same verbs so you do not have to learn a second vocabulary.",287                },288            },289        },290    },291    .{292        .id = "machine-config",293        .title = "machine config",294        .accent = .pink,295        .blocks = &[_]Block{296            .{ .para = "edit your configuration safely, inspect the running system, manage channels, and set boot defaults — all without needing to remember the right file path or nixos-rebuild flag." },297            .{ .para = "open configuration.nix in your configured editor (default: vim). pass `hardware` to open hardware-configuration.nix instead. after saving, run `nina check` to validate before applying." },298            .{ .para = "format configuration.nix with `nixpkgs-fmt`. `--check` reports whether formatting is needed without writing. show the nixos version, kernel version, and uptime of the target machine. list boot entries from the bootloader. launch `nix repl` with nixpkgs loaded." },299            .{300                .callout = .{301                    .face = "(ˊᗜˋ)",302                    .color = .pink,303                    .body = "`nina edit` uses the `editor` value from ~/.config/nina/config (default: vim). set it to your editor of choice and nina will use it everywhere.",304                },305            },306        },307    },308    .{309        .id = "remote",310        .title = "remote machines",311        .accent = .lav,312        .blocks = &[_]Block{313            .{ .para = "the same voice, even over SSH. every nina command accepts `--on <machine>` and behaves identically on a remote host. no separate tooling, no context switching." },314            .{ .cmd_block = "$ nina apply --on azula\n  ✦ rebuilding azula over ssh\n  · building generation 193\n  generation 193.\n\n$ nina service logs ollama -f --on azula\n  ✦ following logs for ollama on azula\n  [info] serving on 0.0.0.0:11434" },315            .{ .para = "define remote machines in ~/.config/nina/config:" },316            .{ .cmd_block = "[machine]\nname    = azula\nhost    = june@azula\nssh_key = ~/.ssh/id_ed25519" },317            .{ .para = "fleet commands:" },318            .{319                .flags_row = .{ .flag = "nina hello", .desc = "list all configured machines and their kind (local / ssh)" },320            },321            .{322                .flags_row = .{ .flag = "nina status --on <m>", .desc = "show generation and uptime for a remote machine" },323            },324            .{325                .flags_row = .{ .flag = "nina doctor --on <m>", .desc = "run a full health diagnostic on any machine" },326            },327            .{328                .flags_row = .{ .flag = "nina apply --on <m>", .desc = "rebuild and switch a remote machine over SSH" },329            },330            .{331                .callout = .{332                    .face = "(˶ᵔ ᵕ ᵔ˶)",333                    .color = .lav,334                    .body = "nina keeps remote work from turning into a whole new personality. same verbs, same little reassurances, same sense of where you are.",335                },336            },337        },338    },339    .{340        .id = "hooks",341        .title = "hooks",342        .accent = .mint,343        .blocks = &[_]Block{344            .{ .para = "nina can run optional executable scripts before and after state-changing commands. hooks live in ~/.config/nina/hooks/. missing hooks are ignored, and files that are not executable are treated as absent." },345            .{ .para = "available hook files:" },346            .{ .cmd_block = "pre-apply     post-apply\npre-back      post-back\npre-home      post-home\npre-upgrade   post-upgrade" },347            .{ .para = "`pre-apply` runs before `nina apply`, `pre-back` before `nina back`, `pre-home` before `nina home apply`, and `pre-upgrade` before `nina upgrade`. the matching `post-*` hooks run only after the command succeeds." },348            .{ .para = "if a pre-hook exits non-zero, nina shows the exit code and the last five lines of hook output, then asks whether to continue. that prompt defaults to no." },349            .{ .cmd_block = ":: pre-apply hook exited with code 1\n\n   uncommitted changes - commit before applying\n\n   continue anyway? [y/N]  n\n:: aborted" },350            .{ .para = "if a post-hook fails, nina prints a warning after the success line. the original command already succeeded, so the hook warning does not turn the operation into a failure." },351            .{ .para = "example: stop `nina apply` when your nixos config has uncommitted changes." },352            .{ .cmd_block = "#!/usr/bin/env sh\n# ~/.config/nina/hooks/pre-apply\nif git -C ~/nixos-config status --porcelain | grep -q .; then\n    echo \"uncommitted changes - commit before applying\"\n    exit 1\nfi" },353            .{ .para = "make hook scripts executable with `chmod +x ~/.config/nina/hooks/pre-apply`. nina creates the hooks directory automatically the first time it checks for hooks." },354        },355    },356    .{357        .id = "configuration",358        .title = "configuration",359        .accent = .gold,360        .blocks = &[_]Block{361            .{ .para = "nina's config file lives at ~/.config/nina/config (XDG). the format is plain key=value, one per line. comments start with #. all settings are optional — nina works out of the box with no config file. (´ω`)" },362            .{363                .callout = .{364                    .face = "(っ˘ω˘ς)",365                    .color = .gold,366                    .body = "if you have an existing ~/.nina.conf from an earlier version, nina migrates it to ~/.config/nina/config automatically on first run.",367                },368            },369            .{ .para = "full example:" },370            .{ .cmd_block = "editor      = nvim\ngenerations = 5\nconfirm     = true\nteach       = false\ncolor       = true\n\n[machine]\nname    = kyoshi\nconfig  = /etc/nixos\nlocal   = true\ndefault = true\n\n[machine]\nname    = azula\nhost    = june@azula\nssh_key = ~/.ssh/id_ed25519" },371            .{ .para = "if you have a [machine] host set, `local` automatically becomes false and `nina --on azula` runs everything over SSH. `default = true` on a machine block selects it when no `--on` is given." },372            .{ .para = "every config key accepts the values you'd expect. booleans read true / false / yes / no. integers are decimal. `~` in `ssh_key` expands to `$HOME`." },373        },374    },375    .{376        .id = "expressions",377        .title = "expressions ♡",378        .accent = .pink,379        .blocks = &[_]Block{380            .{ .para = "nina uses a few small kaomoji expressions instead of a mascot. they stay tucked into the UI, show a little feeling, and leave the work itself readable." },381            .{ .para = "the rule is simple: calm most of the time, brighter when something lands, softer when something goes wrong." },382            .{383                .kaomoji_card = .{384                    .face = "(˶ᵔ ᵕ ᵔ˶)",385                    .name = "hello",386                    .color = .lav,387                    .desc = "a warm, quiet welcome. nina is ready to help and not making a fuss about it.",388                    .when = "setup wizard, first-run greeting",389                },390            },391            .{392                .kaomoji_card = .{393                    .face = "(っ˘ω˘ς)",394                    .name = "starting",395                    .color = .mint,396                    .desc = "settling in and getting to work. something meaningful is beginning.",397                    .when = "apply start, home apply start, entering a dev shell",398                },399            },400            .{401                .kaomoji_card = .{402                    .face = "╰(*°▽°*)╯",403                    .name = "relief",404                    .color = .gold,405                    .desc = "something that was broken is working again. a small celebration for persistence.",406                    .when = "first success after a previously failed build",407                },408            },409            .{410                .kaomoji_card = .{411                    .face = "( ˘ᵕ˘ )",412                    .name = "done",413                    .color = .lav,414                    .desc = "calm satisfaction. a rollback landed cleanly and the system is in good hands.",415                    .when = "back success",416                },417            },418            .{419                .kaomoji_card = .{420                    .face = "( ´ ∀ ` )",421                    .name = "clean",422                    .color = .mint,423                    .desc = "light and tidy. the work is done and things look better than before.",424                    .when = "clean done",425                },426            },427            .{428                .kaomoji_card = .{429                    .face = "( ´ ▽ ` )",430                    .name = "all good",431                    .color = .gold,432                    .desc = "everything checked out. a quiet joy that the system is healthy.",433                    .when = "doctor all clear, mood all good",434                },435            },436            .{437                .kaomoji_card = .{438                    .face = "(˘ω˘ )",439                    .name = "exiting",440                    .color = .lav,441                    .desc = "a gentle close. nothing went wrong — it's just time to step out.",442                    .when = "try exit, leaving a dev shell",443                },444            },445            .{446                .kaomoji_card = .{447                    .face = "(;ω;)",448                    .name = "sorry",449                    .color = .pink,450                    .desc = "something went sideways, and nina is honest about it. details are right there with her.",451                    .when = "network or SSH errors, search timeout",452                },453            },454            .{455                .callout = .{456                    .face = "( ´•̥̥̥ω•̥̥̥` )",457                    .color = .pink,458                    .body = "if you prefer a quieter experience, set `color = false` in your config. nina will still show everything — just without the ansi palette.",459                },460            },461        },462    },463    .{464        .id = "tips",465        .title = "tips & recipes",466        .accent = .mint,467        .blocks = &[_]Block{468            .{ .para = "a few patterns that make daily nixos life a little smoother. (*˘︶˘*)" },469            .{ .para = "a quiet morning with nina:" },470            .{ .cmd_block = "# update flake inputs and preview changes\n$ nina flake update\n$ nina diff\n\n# apply if the diff looks good\n$ nina apply" },471            .{ .para = "try a package without committing to it:" },472            .{ .cmd_block = "$ nina try bat\n:: trying bat  exit when done\n   → bat is available in this shell only, gone when you exit" },473            .{ .para = "push the same config change to multiple machines:" },474            .{ .cmd_block = "$ nina apply --on kyoshi\n:: generation 349  [4.1s]\n$ nina apply --on azula\n:: generation 193  [6.3s]" },475            .{ .para = "something broke and you want to find when:" },476            .{ .cmd_block = "$ nina history\n  → find the last known-good generation number\n$ nina diff 344 349\n  → see what changed across those generations\n$ nina go 344\n:: done  gen 344" },477            .{ .para = "useful shell aliases:" },478            .{ .cmd_block = "alias ns=\"nina search\"\nalias no=\"nina option\"\nalias na=\"nina apply\"\nalias nd=\"nina diff\"\nalias nb=\"nina back\"" },479            .{480                .callout = .{481                    .face = "(≧◡≦)",482                    .color = .mint,483                    .body = "when something goes wrong, `nina back` is always there. when you're not sure what to try next, `nina diff` shows you the picture. and when things go right, nina keeps the terminal feeling light.",484                },485            },486        },487    },488};489490// --- Pager state ---491492// Visible content rows. Set once per run from the real terminal height, so493// the pager fills the window instead of a fixed guess that left half the494// screen unused on tall terminals. Falls back to this default when the495// window size can't be read.496var PAGE_ROWS: u16 = 22;497const MIN_PAGE_ROWS: u16 = 5;498const COL_WIDTH: u16 = 96; // wrap width for paragraphs499// Frame geometry. The header paints exactly HEADER_ROWS lines and the footer500// FOOTER_ROWS; redraw arithmetic depends on these being right, so name them501// instead of magic +2 guesses that drift.502const HEADER_ROWS: u16 = 3;503const FOOTER_ROWS: u16 = 1;504fn frameRows() u16 {505    return HEADER_ROWS + PAGE_ROWS + FOOTER_ROWS;506}507508// Reads the terminal's row count via TIOCGWINSZ and converts it to a content509// row budget by subtracting the fixed header/footer. Any failure (not a tty,510// ioctl error, absurdly small window) falls back to the default PAGE_ROWS.511fn detectPageRows(io: std.Io) u16 {512    var ws: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 };513    const result = io.operate(.{ .device_io_control = .{514        .file = std.Io.File.stdout(),515        .code = std.posix.T.IOCGWINSZ,516        .arg = &ws,517    } }) catch return PAGE_ROWS;518    if (result.device_io_control < 0) return PAGE_ROWS;519    if (ws.row <= HEADER_ROWS + FOOTER_ROWS + MIN_PAGE_ROWS) return PAGE_ROWS;520    return ws.row - HEADER_ROWS - FOOTER_ROWS;521}522523pub fn run(io: std.Io, topic: []const u8) !void {524    // Plain fallback for non-tty (piped, redirected). Print every section in525    // order with a colored chapter divider — same vibe, but no interactivity.526    if (!(std.Io.File.stdout().isTty(io) catch false)) {527        return runPlain(io, topic);528    }529530    // Raw mode so we can read keys without waiting for Enter. Match search.zig.531    var saved: std.posix.termios = undefined;532    try rawterm.rawModeEnable(STDIN_FD, &saved);533    defer rawterm.rawModeDisable(STDIN_FD, saved);534535    PAGE_ROWS = detectPageRows(io);536537    const start_section: usize = if (topic.len == 0) 0 else blk: {538        for (MAN_PAGE, 0..) |sec, i| {539            if (std.mem.eql(u8, sec.id, topic)) break :blk i;540        }541        output.p("{s}nina man: no such topic: {s}{s}\n", .{ c(PINK), topic, c(RESET) });542        output.flush();543        return;544    };545546    // Pre-render every section into an array of styled lines so the page loop547    // never has to format anything — it just slices the right range. This keeps548    // redraw cost constant and makes `g`/`G` instant.549    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);550    defer arena.deinit();551    const allocator = arena.allocator();552553    var rendered = std.ArrayList([]const u8).empty;554    defer rendered.deinit(allocator);555556    // Per-section, prepend a chapter divider so the content has visual breathing557    // room and the pager can show progress.558    var section_starts = std.ArrayList(usize).empty;559    defer section_starts.deinit(allocator);560    var section_titles = std.ArrayList([]const u8).empty;561    defer section_titles.deinit(allocator);562563    try section_starts.append(allocator, 0);564    for (MAN_PAGE) |sec| {565        try section_titles.append(allocator, sec.title);566        try renderSection(allocator, &rendered, sec);567        try section_starts.append(allocator, rendered.items.len);568    }569    const total: usize = rendered.items.len;570571    var top: usize = 0;572    var cur_section: usize = start_section;573    top = section_starts.items[start_section];574575    drawHeader();576    drawBody(rendered.items, top, total, section_titles.items, cur_section);577    drawFooter(cur_section, MAN_PAGE.len, top, total);578    output.flush();579580    var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};581    while (true) {582        _ = std.posix.poll(&pfd, -1) catch break;583584        var buf: [8]u8 = undefined;585        const n = std.posix.read(STDIN_FD, &buf) catch break;586        if (n == 0) break;587588        const advance_section = struct {589            fn f(starts: []const usize, cur: usize, delta: i32) usize {590                var idx: i32 = @intCast(cur);591                idx += delta;592                if (idx < 0) idx = 0;593                if (idx >= @as(i32, @intCast(starts.len - 1))) idx = @intCast(starts.len - 2);594                return starts[@intCast(idx)];595            }596        }.f;597598        // Find the section that owns `top`. Used after manual scrolling.599        const sectionOf = struct {600            fn f(starts: []usize, t: usize) usize {601                var s: usize = 0;602                for (starts[1..], 1..) |start, i| {603                    if (t < start) return i - 1;604                    s = i;605                }606                return s;607            }608        }.f;609610        var redraw = true;611        if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {612            switch (buf[2]) {613                'A' => top = if (top > 0) top - 1 else 0, // up614                'B' => top = if (top + 1 < total) top + 1 else top, // down615                'C' => top = advance_section(section_starts.items, cur_section, 1), // right: next section616                'D' => top = advance_section(section_starts.items, cur_section, -1), // left: prev617                '5' => top = if (top >= PAGE_ROWS) top - PAGE_ROWS else 0, // pgup618                '6' => top = if (top + PAGE_ROWS < total) top + PAGE_ROWS else total - 1, // pgdn619                'H' => top = 0, // home620                'F' => top = if (total > 0) total - 1 else 0, // end621                else => redraw = false,622            }623        } else switch (buf[0]) {624            'q', 'Q', 0x1b => break, // quit625            'j' => top = if (top + 1 < total) top + 1 else top,626            'k' => top = if (top > 0) top - 1 else 0,627            ' ' => top = if (top + PAGE_ROWS < total) top + PAGE_ROWS else total - 1,628            'b' => top = if (top >= PAGE_ROWS) top - PAGE_ROWS else 0,629            'g' => top = 0,630            'G' => top = if (total > 0) total - 1 else 0,631            'n' => top = advance_section(section_starts.items, cur_section, 1),632            'p' => top = advance_section(section_starts.items, cur_section, -1),633            'h' => top = 0,634            '?' => {635                showHelp();636                // showHelp leaves the help screen on the terminal until the637                // next redraw; force one now instead of waiting for the next638                // navigation key, so dismissing help doesn't leave stale639                // help content on screen (NINA-015).640                redraw = true;641            },642            else => redraw = false,643        }644645        if (redraw) {646            cur_section = sectionOf(section_starts.items, top);647            // Header is static; repaint only the body + footer below it. Move up648            // exactly the rows we repaint. The old `+ 2` overshot by one, so each649            // redraw crept the frame up a row and left the previous footer650            // behind as a ghost line.651            output.cursorUp(PAGE_ROWS + FOOTER_ROWS);652            drawBody(rendered.items, top, total, section_titles.items, cur_section);653            drawFooter(cur_section, MAN_PAGE.len, top, total);654            output.flush();655        }656    }657658    // Clear the whole inline frame on exit — header included — so the shell659    // prompt lands on a clean line. The old PAGE_ROWS + 2 count missed the top660    // two header rows, leaving them stranded above the prompt.661    output.cursorUp(frameRows());662    for (0..frameRows()) |_| {663        output.raw("\x1b[K\n");664    }665    output.cursorUp(frameRows());666    output.flush();667}668669fn runPlain(_: std.Io, topic: []const u8) !void {670    const start_section: usize = if (topic.len == 0) 0 else blk: {671        for (MAN_PAGE, 0..) |sec, i| {672            if (std.mem.eql(u8, sec.id, topic)) break :blk i;673        }674        output.p("{s}nina man: no such topic: {s}{s}\n", .{ c(PINK), topic, c(RESET) });675        output.flush();676        return;677    };678679    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);680    defer arena.deinit();681    const allocator = arena.allocator();682683    var rendered = std.ArrayList([]const u8).empty;684    defer rendered.deinit(allocator);685686    var i: usize = start_section;687    while (i < MAN_PAGE.len) : (i += 1) {688        try renderSection(allocator, &rendered, MAN_PAGE[i]);689    }690    for (rendered.items) |line| output.raw(line);691    output.raw("\n");692    output.flush();693}694695const STDIN_FD = rawterm.STDIN_FD;696697// Raw mode enable/disable, including signal-safe restoration on698// SIGINT/SIGTERM/SIGHUP, now lives in rawterm.zig — shared with search.zig699// (NINA-014).700701// --- Rendering ---702703const AccentColor = struct {704    fg: []const u8,705    bar: []const u8,706};707708fn accentColors(a: Block.Accent) AccentColor {709    return switch (a) {710        .gold => .{ .fg = GOLD, .bar = GOLD },711        .mint => .{ .fg = MINT, .bar = MINT },712        .pink => .{ .fg = PINK, .bar = PINK },713        .lav => .{ .fg = LAV, .bar = LAV },714    };715}716717fn renderSection(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), sec: Section) !void {718    // Chapter divider — small caps with the section index number719    var idx_buf: [16]u8 = undefined;720    const idx_str = std.fmt.bufPrint(&idx_buf, "{d:0>2}", .{sectionIndexOf(sec.id) + 1}) catch "00";721722    const accent = accentColors(sec.accent);723    const divider = try std.fmt.allocPrint(allocator, "{s}── {s}{s}{s}  {s}{s}{s}\n", .{724        c(TEXT_DIM),725        c(accent.fg), sec.title, c(RESET),726        c(TEXT_DIM), idx_str, c(RESET),727    });728    try out.append(allocator, divider);729730    for (sec.blocks) |block| {731        try renderBlock(allocator, out, block);732    }733734    // Soft blank line at end of section735    try out.append(allocator, "\n");736}737738fn sectionIndexOf(id: []const u8) usize {739    for (MAN_PAGE, 0..) |sec, i| {740        if (std.mem.eql(u8, sec.id, id)) return i;741    }742    return 0;743}744745fn renderBlock(allocator: std.mem.Allocator, out: *std.ArrayList([]const u8), block: Block) !void {746    switch (block) {747        .chapter => try out.append(allocator, try std.fmt.allocPrint(allocator, "\n{s}{s}  {s}{s}\n\n", .{ c(TEXT_DIM), block.chapter, c(RESET), "" })),748        .title => {749            const accent = accentColors(block.accent);750            const padded_title = try std.fmt.allocPrint(allocator, "  {s}◆{s} {s}{s}{s}  ", .{ c(accent.bar), c(RESET), c(BOLD), block.title, c(RESET) });751            try out.append(allocator, try std.fmt.allocPrint(allocator, "\n{s}{s}\n", .{ padded_title, "" }));752            if (block.badge.len > 0) {753                try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}[{s}{s}{s}{s}]{s}\n", .{ c(TEXT_DIM), c(accent.fg), block.badge, c(TEXT_DIM), c(RESET), c(RESET) }));754            }755        },756        .badge => {}, // handled by title757        .accent => {}, // marker field — ignored758        .para => {759            const wrapped = try wrap(allocator, block.para, COL_WIDTH);760            for (wrapped) |line| {761                try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}{s}{s}\n", .{ c(TEXT), line, c(RESET) }));762            }763        },764        .cmd_sig => {765            const accent = accentColors(block.accent);766            try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}│{s}  {s}{s}{s}\n", .{ c(accent.fg), c(RESET), c(accent.fg), block.cmd_sig, c(RESET) }));767        },768        .cmd_block => {769            const wrapped = try wrap(allocator, block.cmd_block, COL_WIDTH);770            for (wrapped) |line| {771                try out.append(allocator, try std.fmt.allocPrint(allocator, "    {s}{s}{s}\n", .{ c(MINT), line, c(RESET) }));772            }773            try out.append(allocator, "\n");774        },775        .flags_row => {776            const accent = accentColors(.gold);777            try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}│{s}  {s}{s:<22}{s}  {s}{s}{s}\n", .{ c(accent.fg), c(RESET), c(accent.fg), block.flags_row.flag, c(RESET), c(TEXT), block.flags_row.desc, c(RESET) }));778        },779        .callout => {780            const accent = accentColors(block.callout.color);781            const wrapped = try wrap(allocator, block.callout.body, COL_WIDTH - 6);782            var first = true;783            for (wrapped) |line| {784                if (first) {785                    try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}┌─ {s}{s}  {s}{s}{s}\n", .{ c(accent.bar), c(accent.fg), block.callout.face, c(TEXT), line, c(RESET) }));786                    first = false;787                } else {788                    try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}│{s}   {s}{s}{s}\n", .{ c(accent.bar), c(RESET), c(TEXT), line, c(RESET) }));789                }790            }791            try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}└─{s}\n", .{ c(accent.bar), c(RESET) }));792        },793        .kaomoji_card => {794            const accent = accentColors(block.kaomoji_card.color);795            // Top: kaomoji in its accent color, name to the right796            try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}┌── {s}{s}  {s}{s}{s}\n", .{ c(accent.bar), c(accent.fg), block.kaomoji_card.face, c(BOLD), block.kaomoji_card.name, c(RESET) }));797            const desc_lines = try wrap(allocator, block.kaomoji_card.desc, COL_WIDTH - 10);798            for (desc_lines) |line| {799                try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}│{s}   {s}{s}{s}\n", .{ c(accent.bar), c(RESET), c(TEXT), line, c(RESET) }));800            }801            try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}│{s}   {s}when:{s}  {s}{s}{s}\n", .{ c(accent.bar), c(RESET), c(TEXT_DIM), c(RESET), c(TEXT_DIM), block.kaomoji_card.when, c(RESET) }));802            try out.append(allocator, try std.fmt.allocPrint(allocator, "  {s}└──{s}\n", .{ c(accent.bar), c(RESET) }));803        },804        .blank => try out.append(allocator, "\n"),805    }806}807808// Word-wrap to width, breaking at whitespace. Caller owns the returned slice.809fn wrap(allocator: std.mem.Allocator, text: []const u8, width: u16) ![]const []const u8 {810    var lines = std.ArrayList([]const u8).empty;811    errdefer lines.deinit(allocator);812813    var it = std.mem.splitScalar(u8, text, '\n');814    while (it.next()) |para| {815        if (para.len == 0) {816            try lines.append(allocator, "");817            continue;818        }819        var start: usize = 0;820        while (start < para.len) {821            var end = @min(start + @as(usize, width), para.len);822            if (end < para.len) {823                var back: usize = end;824                while (back > start and para[back - 1] != ' ') : (back -= 1) {}825                if (back > start) end = back;826            }827            try lines.append(allocator, std.mem.trim(u8, para[start..end], " \t"));828            start = end;829            while (start < para.len and para[start] == ' ') : (start += 1) {}830        }831    }832    return lines.toOwnedSlice(allocator);833}834835// --- Screen rendering ---836837fn drawHeader() void {838    // Three-row header: face line, title line, divider839    output.raw("\x1b[K");840    output.p("{s}{s} nina  man  {s}the whole picture · press ? for keys · q to leave{s}\n", .{ c(BG_BAR), c(PINK), c(TEXT), c(RESET) });841    output.raw("\x1b[K");842    output.p("{s}    {s}(˶ᵔ ᵕ ᵔ˶){s}  ready when you are{s}\n", .{ c(BG_HI), c(PINK), c(RESET), c(RESET) });843    output.raw("\x1b[K");844    output.p("{s}{s}{s}\n", .{ c(TEXT_DIM), "─" ** 80, c(RESET) });845}846847fn drawBody(lines: []const []const u8, top: usize, total: usize, titles: []const []const u8, cur_section: usize) void {848    _ = total;849    _ = titles;850    _ = cur_section;851    // Each iteration must advance the cursor exactly one row: \x1b[K wipes the852    // previous frame's leftovers on this row, then the line's own trailing853    // newline moves down. The old chapter-divider special case wrote nothing —854    // no newline — so a frame with a divider mid-viewport drew fewer rows than855    // the cursor had climbed, and stale rows bled through as ghosting.856    var i: usize = 0;857    while (i < PAGE_ROWS) : (i += 1) {858        output.raw("\x1b[K");859        const idx = top + i;860        if (idx < lines.len) {861            output.raw(lines[idx]);862        } else {863            output.raw("\n");864        }865    }866}867868fn drawFooter(cur_section: usize, total_sections: usize, top: usize, total: usize) void {869    const pct: usize = if (total == 0) 0 else (top * 100) / total;870    output.raw("\x1b[K");871    var sec_buf: [16]u8 = undefined;872    const sec_str = std.fmt.bufPrint(&sec_buf, "{d}", .{cur_section + 1}) catch "?";873    var tot_buf: [16]u8 = undefined;874    const tot_str = std.fmt.bufPrint(&tot_buf, "{d}", .{total_sections}) catch "?";875    var pct_buf: [16]u8 = undefined;876    const pct_str = std.fmt.bufPrint(&pct_buf, "{d}", .{pct}) catch "?";877    output.p("{s}{s}───  {s}{s:>2}/{s}{s}  {s}{s:>3}%{s}  {s}·  ↑↓ scroll  ·  n/p next/prev section  ·  q quit{s}\n", .{878        c(TEXT_DIM), c(BG_HI),879        c(MINT),   sec_str, tot_str, c(RESET),880        c(GOLD),   pct_str, c(RESET),881        c(TEXT_DIM), c(RESET),882    });883}884885fn showHelp() void {886    output.cursorUp(frameRows());887    for (0..frameRows()) |_| output.raw("\x1b[K\n");888    output.cursorUp(frameRows());889    drawHeader();890    drawHelpBody();891    drawFooter(0, MAN_PAGE.len, 0, 1);892    output.flush();893894    // wait for any key895    var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};896    _ = std.posix.poll(&pfd, -1) catch {};897    var buf: [8]u8 = undefined;898    _ = std.posix.read(STDIN_FD, &buf) catch {};899}900901fn drawHelpBody() void {902    const pairs = [_]struct { key: []const u8, desc: []const u8 }{903        .{ .key = "j  /  ↓", .desc = "scroll down one line" },904        .{ .key = "k  /  ↑", .desc = "scroll up one line" },905        .{ .key = "space", .desc = "page down" },906        .{ .key = "b  /  pgup", .desc = "page up" },907        .{ .key = "g  /  home", .desc = "jump to top" },908        .{ .key = "G  /  end", .desc = "jump to bottom" },909        .{ .key = "n  /  →", .desc = "next section" },910        .{ .key = "p  /  ←", .desc = "previous section" },911        .{ .key = "h", .desc = "back to top (table of contents)" },912        .{ .key = "?", .desc = "this help" },913        .{ .key = "q  /  esc", .desc = "leave the manpage" },914    };915    // Every iteration must emit exactly one row (one trailing "\n") — the916    // i == 0 arm used to print a title line plus a blank spacer line917    // ("\n\n") in a single iteration, so the help frame came out PAGE_ROWS+1918    // rows tall while every redraw's `cursorUp(PAGE_ROWS + FOOTER_ROWS)`919    // assumed exactly PAGE_ROWS, drifting the frame up a row on every help920    // dismiss/redraw (same ghosting-drift bug class as the v3.4.5 body fix,921    // NINA-015).922    var i: usize = 0;923    while (i < PAGE_ROWS) : (i += 1) {924        output.raw("\x1b[K");925        if (i == 0) {926            output.p("  {s}{s}nina man · keys{s}\n", .{ c(BOLD), c(PINK), c(RESET) });927        } else if (i - 1 < pairs.len) {928            const p = pairs[i - 1];929            output.p("    {s}{s:<14}{s}  {s}{s}{s}\n", .{ c(GOLD), p.key, c(RESET), c(TEXT), p.desc, c(RESET) });930        } else {931            output.raw("\n");932        }933    }934}935936// --- Ghosting-drift regression tests (v3.4.5 fixed the body; this extends937// coverage to the help screen, NINA-015) ---938//939// The redraw arithmetic in run()'s key loop (`cursorUp(PAGE_ROWS +940// FOOTER_ROWS)`) only stays correct if every screen-drawing function emits941// exactly the row count its name promises. These tests capture real output942// through output.zig (the same module the TUI writes through) and count943// newlines, so a regression that re-introduces an extra or missing row in944// either function fails loudly instead of drifting the frame on-screen.945946fn newlineCount(bytes: []const u8) usize {947    return std.mem.count(u8, bytes, "\n");948}949950test "drawHelpBody emits exactly PAGE_ROWS rows regardless of PAGE_ROWS' value" {951    const saved_page_rows = PAGE_ROWS;952    defer PAGE_ROWS = saved_page_rows;953954    var writer: std.Io.Writer.Allocating = .init(std.testing.allocator);955    defer writer.deinit();956    var env: std.process.Environ.Map = .init(std.testing.allocator);957    defer env.deinit();958    output.init(&writer.writer, std.testing.io, &env, false);959960    for ([_]u16{ 5, 12, MIN_PAGE_ROWS }) |rows| {961        PAGE_ROWS = rows;962        writer.clearRetainingCapacity();963        drawHelpBody();964        try std.testing.expectEqual(@as(usize, rows), newlineCount(writer.written()));965    }966}967968test "drawBody emits exactly PAGE_ROWS rows" {969    const saved_page_rows = PAGE_ROWS;970    defer PAGE_ROWS = saved_page_rows;971    PAGE_ROWS = 6;972973    var writer: std.Io.Writer.Allocating = .init(std.testing.allocator);974    defer writer.deinit();975    var env: std.process.Environ.Map = .init(std.testing.allocator);976    defer env.deinit();977    output.init(&writer.writer, std.testing.io, &env, false);978979    const lines = [_][]const u8{ "one\n", "two\n", "three\n" };980    drawBody(&lines, 0, lines.len, &.{}, 0);981982    try std.testing.expectEqual(@as(usize, PAGE_ROWS), newlineCount(writer.written()));983}984985// NINA-017/019 drift guard: the man page and README are hand-maintained prose986// that mirror the actual config path, log path, and search widget keybindings.987// Past drift (fable audit 2026-07-02) left these pointing at a retired988// ~/.nina.conf path, a retired ~/.local/share/nina/nina.log log path, and a989// pre-key-collision-fix i/s/t/c keymap that no longer exists in search.zig.990// This walks MAN_PAGE's own text plus README.md looking for those exact991// retired strings so the drift can't silently reappear. The config section's992// migration note is the one legitimate historical mention of ~/.nina.conf993// left in MAN_PAGE (it explains the auto-migration), so that string is994// allowed at most once there, not zero.995fn collectManPageText(gpa: std.mem.Allocator) ![]u8 {996    var buf: std.ArrayList(u8) = .empty;997    errdefer buf.deinit(gpa);998    for (MAN_PAGE) |section| {999        try buf.appendSlice(gpa, section.title);1000        try buf.append(gpa, '\n');1001        for (section.blocks) |block| {1002            switch (block) {1003                .chapter, .title, .badge, .para, .cmd_sig, .cmd_block => |s| {1004                    try buf.appendSlice(gpa, s);1005                    try buf.append(gpa, '\n');1006                },1007                .flags_row => |r| {1008                    try buf.appendSlice(gpa, r.flag);1009                    try buf.append(gpa, '\n');1010                    try buf.appendSlice(gpa, r.desc);1011                    try buf.append(gpa, '\n');1012                },1013                .callout => |co| {1014                    try buf.appendSlice(gpa, co.body);1015                    try buf.append(gpa, '\n');1016                },1017                .kaomoji_card => |k| {1018                    try buf.appendSlice(gpa, k.name);1019                    try buf.append(gpa, '\n');1020                    try buf.appendSlice(gpa, k.desc);1021                    try buf.append(gpa, '\n');1022                    try buf.appendSlice(gpa, k.when);1023                    try buf.append(gpa, '\n');1024                },1025                .accent, .blank => {},1026            }1027        }1028    }1029    return buf.toOwnedSlice(gpa);1030}10311032test "MAN_PAGE and README stay free of retired config/search-key/log-path strings" {1033    const gpa = std.testing.allocator;1034    const text = try collectManPageText(gpa);1035    defer gpa.free(text);10361037    try std.testing.expect(std.mem.count(u8, text, "~/.nina.conf") <= 1);1038    try std.testing.expect(std.mem.count(u8, text, "c copy attr") == 0);1039    try std.testing.expect(std.mem.count(u8, text, "~/.local/share/nina/nina.log") == 0);10401041    // README.md lives outside src/'s package boundary, so it's read at its1042    // build-time-baked absolute path (build_options.readme_path) rather than1043    // @embedFile'd, and it's read fresh here rather than cached, so editing1044    // README.md is picked up without touching this file.1045    const readme = try std.Io.Dir.readFileAlloc(.cwd(), std.testing.io, build_options.readme_path, gpa, .unlimited);1046    defer gpa.free(readme);1047    try std.testing.expect(std.mem.count(u8, readme, "~/.nina.conf") == 0);1048    try std.testing.expect(std.mem.count(u8, readme, "c copy attr") == 0);1049    try std.testing.expect(std.mem.count(u8, readme, "~/.local/share/nina/nina.log") == 0);1050}