Skip to main content

kaish_help/
compose.rs

1//! Composition surface: assemble canonical kaish guidance for an audience.
2//!
3//! Content is a set of [`Fragment`]s keyed by [`Concept`] / [`Variant`] / locale.
4//! A [`Selector`] (or a ready-made [`Recipe`]) chooses which fragments to render;
5//! [`compose`] assembles them into a single markdown string. Live, schema-derived
6//! content (the builtin index, per-tool help) is injected through the
7//! [`GeneratedContent`] trait so this crate stays free of the tool registry.
8//!
9//! Design + resolved decisions: `docs/composable-help.md`.
10
11use std::collections::HashMap;
12
13use kaish_types::ToolSchema;
14
15use crate::fragments::FRAGMENTS;
16use crate::topic::tool_help;
17
18/// The "what" — concept taxonomy, organized for learning, not by audience.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Concept {
21    /// Mental model: kernel/核, VFS, structured data, pre-validation.
22    Model,
23    /// Grammar: variables, expansion, quoting, pipes, control flow.
24    Syntax,
25    /// The operating contract — guarantees AND the idioms that follow from them.
26    /// The agent-onboarding spine (renamed from "Consistency"; see design doc).
27    Foundations,
28    /// Generated: tool index + per-tool help (from [`ToolSchema`]).
29    Builtins,
30    /// Intentionally-missing features, known limitations, ShellCheck alignment.
31    Limits,
32    /// Copy-on-write overlay mode (`--overlay`, `kaish-vfs`) — opt-in, not part of
33    /// the default onboarding spine. Split out of [`Self::Foundations`] because it
34    /// teaches a mode most embedders never enable: kaijutsu materializes a fresh
35    /// kernel per call and never turns overlay on, and kaibo's read-only sandbox
36    /// used to hand-strip this paragraph out of `Recipe::tool_description()`
37    /// (paragraph-splitting on the bold heading, `strip_write_side_paragraphs`)
38    /// because it contradicted "writes are refused" one paragraph later. An
39    /// embedder that *does* use overlay opts in with [`Selector::with_overlay`].
40    Overlay,
41    // Capabilities — deferred until the capability-feature split gives it a body.
42}
43
44impl Concept {
45    /// Human-readable section title used when composing.
46    pub fn title(&self) -> &'static str {
47        match self {
48            Self::Model => "About kaish",
49            Self::Syntax => "Syntax",
50            Self::Foundations => "How kaish works",
51            Self::Builtins => "Builtins",
52            Self::Limits => "Limitations",
53            Self::Overlay => "Overlay mode",
54        }
55    }
56}
57
58/// The "how it's said" — variations of one idea, used to reinforce.
59///
60/// There is deliberately no Style/Guidance variant: idiomatic best-practice
61/// ("prefer `--json`") is foundational *content* (the [`Concept::Foundations`]
62/// concept), not a rendering. See `docs/composable-help.md` (resolved Q2).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum Variant {
65    /// Terse imperative ("use `--json` for structured output").
66    Rule,
67    /// Worked snippet (`ls --json | jq '.[].name'`).
68    Example,
69    /// How bash differs ("bash makes you parse `ls` text").
70    Contrast,
71    /// Why kaish chose this ("every builtin emits structured data").
72    Rationale,
73}
74
75impl Variant {
76    /// Stable order within a concept/key: rule, then example, contrast, rationale.
77    fn order(&self) -> u8 {
78        match self {
79            Self::Rule => 0,
80            Self::Example => 1,
81            Self::Contrast => 2,
82            Self::Rationale => 3,
83        }
84    }
85}
86
87/// Who the rendered content is for. A *lens*, not a fork: most fragments are
88/// shared (`audience: None`); the rare divergence is `Some(_)`.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum Audience {
91    /// An agent driving kaish (embedded) — terse, behavior-focused.
92    Agent,
93    /// A human at the REPL — welcome + discoverability.
94    Human,
95}
96
97/// How much to include. `Summary` is the always-on core; `Reference` adds detail.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
99pub enum Depth {
100    /// Just the load-bearing material.
101    Summary,
102    /// Everything, including examples and rationale.
103    Reference,
104}
105
106/// Default (and canonical-complete) content locale.
107pub const DEFAULT_LOCALE: &str = "en";
108
109/// The importance rank of an unranked fragment: sorts last, so a fragment with
110/// no explicit rank keeps its registry position relative to other unranked
111/// fragments. Only the always-on onboarding spine assigns explicit ranks.
112pub const UNRANKED: u8 = u8::MAX;
113
114/// A unit of content, addressed by (concept, key, variant, locale).
115pub struct Fragment {
116    /// Concept this fragment belongs to.
117    pub concept: Concept,
118    /// Sub-topic within the concept, e.g. `"no-word-splitting"`.
119    pub key: &'static str,
120    /// How this fragment renders its idea.
121    pub variant: Variant,
122    /// `Summary` fragments always show; `Reference` only at reference depth.
123    pub depth: Depth,
124    /// BCP-47 locale tag. English (`"en"`) is the canonical-complete base.
125    pub locale: &'static str,
126    /// `None` = shared (default); `Some(_)` = audience-specific divergence.
127    pub audience: Option<Audience>,
128    /// Importance rank for the always-on onboarding block: `0` is the most
129    /// important, and composition renders fragments **in ascending rank** so the
130    /// client model meets the critical rules first even under skimming or
131    /// truncation. Fragments at [`UNRANKED`] (the default) keep registry order.
132    /// The rank is a property of the `(concept, key, variant)` slot, so it is
133    /// locale-agnostic — a translation of a fragment inherits the same rank.
134    pub rank: u8,
135    /// Optional section heading for reference rendering (e.g. `"Quoting"`).
136    /// `None` for inline fragments (the Foundations spine renders under its
137    /// concept header instead). Used by [`render_syntax_reference`].
138    pub title: Option<&'static str>,
139    /// Markdown body.
140    pub body: &'static str,
141}
142
143impl Fragment {
144    /// Attach an importance `rank` (0 = most important) to a fragment, for the
145    /// always-on onboarding block. `const` so it composes in the static registry:
146    /// `en(...).ranked(2)`.
147    pub const fn ranked(self, rank: u8) -> Fragment {
148        Fragment { rank, ..self }
149    }
150}
151
152/// What to compose. Build one directly, or use a [`Recipe`].
153pub struct Selector {
154    /// Concepts to include, in render order.
155    pub concepts: Vec<Concept>,
156    /// Variants to include; empty means all.
157    pub variants: Vec<Variant>,
158    /// Audience lens.
159    pub audience: Audience,
160    /// How much detail.
161    pub depth: Depth,
162    /// Requested locale; falls back to [`DEFAULT_LOCALE`] per slot.
163    pub locale: String,
164    /// Emit `## <concept title>` section headers. Markdown-rendering clients want
165    /// them; a plain-terminal REPL banner does not.
166    pub headers: bool,
167}
168
169impl Selector {
170    /// Opt into [`Concept::Overlay`] guidance (`--overlay`, `kaish-vfs`). Every
171    /// [`Recipe`] excludes it by default — most embedders never enable overlay
172    /// mode, and the paragraph is dead weight (or an active mixed signal for a
173    /// read-only embedder) when they don't. Chain it onto a recipe:
174    /// `Recipe::agent_onboarding().with_overlay()`. A no-op if already present.
175    pub fn with_overlay(mut self) -> Self {
176        if !self.concepts.contains(&Concept::Overlay) {
177            self.concepts.push(Concept::Overlay);
178        }
179        self
180    }
181
182    /// Drop [`Concept::Overlay`] guidance, in case a future recipe or a
183    /// caller-built [`Selector`] included it. Symmetric with
184    /// [`Self::with_overlay`]; a no-op today since no recipe defaults it in.
185    pub fn without_overlay(mut self) -> Self {
186        self.concepts.retain(|c| *c != Concept::Overlay);
187        self
188    }
189}
190
191/// Live, schema-derived content the static fragments can't hold.
192///
193/// The kernel implements this (it owns the tool registry); this crate stays free
194/// of the registry. [`SchemaContent`] is the standard implementation.
195pub trait GeneratedContent {
196    /// `(name, one-line description)` for every available builtin, in list order.
197    fn builtin_index(&self) -> Vec<(String, String)>;
198    /// The schema skeleton for one tool, or `None` if it isn't registered.
199    fn tool_help(&self, name: &str) -> Option<String>;
200}
201
202/// [`GeneratedContent`] backed by a slice of tool schemas.
203pub struct SchemaContent<'a> {
204    schemas: &'a [ToolSchema],
205}
206
207impl<'a> SchemaContent<'a> {
208    /// Wrap a slice of schemas. Pass `&[]` when a recipe needs no generated content.
209    pub fn new(schemas: &'a [ToolSchema]) -> Self {
210        Self { schemas }
211    }
212}
213
214impl GeneratedContent for SchemaContent<'_> {
215    fn builtin_index(&self) -> Vec<(String, String)> {
216        self.schemas
217            .iter()
218            .map(|s| (s.name.clone(), s.description.clone()))
219            .collect()
220    }
221
222    fn tool_help(&self, name: &str) -> Option<String> {
223        tool_help(name, self.schemas)
224    }
225}
226
227/// Whether a fragment passes the selector's audience/depth/variant filters.
228/// (Locale is resolved per slot afterwards, not here.)
229fn applicable(fragment: &Fragment, selector: &Selector) -> bool {
230    let variant_ok = selector.variants.is_empty() || selector.variants.contains(&fragment.variant);
231    let depth_ok = fragment.depth == Depth::Summary || selector.depth == Depth::Reference;
232    let audience_ok = fragment.audience.is_none_or(|a| a == selector.audience);
233    variant_ok && depth_ok && audience_ok
234}
235
236/// Choose the fragments for one concept: filter, preserve **registry order** (it's
237/// author-controlled and pedagogical — not key-sorted), and resolve each
238/// (key, variant) slot to the requested locale, falling back to English.
239fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
240    // Slot order = first appearance in the registry; `chosen` holds the best
241    // locale match per slot (replacing in place keeps the slot's position).
242    let mut order: Vec<(&str, u8)> = Vec::new();
243    let mut chosen: HashMap<(&str, u8), &Fragment> = HashMap::new();
244
245    for fragment in FRAGMENTS
246        .iter()
247        .filter(|f| f.concept == concept && applicable(f, selector))
248    {
249        let slot = (fragment.key, fragment.variant.order());
250        match chosen.get(&slot) {
251            None => {
252                order.push(slot);
253                chosen.insert(slot, fragment);
254            }
255            // Prefer the requested locale; otherwise keep what we have (English
256            // base, by construction of the registry). Position is unchanged.
257            Some(existing) => {
258                if fragment.locale == selector.locale && existing.locale != selector.locale {
259                    chosen.insert(slot, fragment);
260                }
261            }
262        }
263    }
264
265    let mut result: Vec<&Fragment> = order
266        .iter()
267        .filter_map(|slot| chosen.get(slot).copied())
268        .collect();
269    // Render in importance order: ascending rank, ties preserving registry
270    // position (a stable sort). Unranked fragments (`UNRANKED`) sort last and so
271    // keep their registry order among themselves — a no-op for any concept whose
272    // fragments are all unranked (Syntax, Model), which keeps `syntax.md` stable.
273    result.sort_by_key(|f| f.rank);
274    result
275}
276
277/// Compose canonical kaish guidance into a single markdown document.
278///
279/// Markdown is the only render target for now (resolved Q4, YAGNI). Concepts are
280/// rendered in selector order under `##` headers; the `Builtins` concept pulls its
281/// list from `generated`.
282pub fn compose(selector: &Selector, generated: &dyn GeneratedContent) -> String {
283    let mut sections: Vec<String> = Vec::new();
284
285    for &concept in &selector.concepts {
286        let mut body = String::new();
287
288        if concept == Concept::Builtins {
289            let index = generated.builtin_index();
290            if index.is_empty() {
291                continue;
292            }
293            let width = index.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
294            for (name, desc) in index {
295                body.push_str(&format!("  {name:width$}  {desc}\n"));
296            }
297        } else {
298            let fragments = select_for_concept(concept, selector);
299            if fragments.is_empty() {
300                continue;
301            }
302            for (i, fragment) in fragments.iter().enumerate() {
303                if i > 0 {
304                    body.push('\n');
305                }
306                body.push_str(fragment.body.trim_end());
307                body.push('\n');
308            }
309        }
310
311        let body = body.trim_end();
312        if selector.headers {
313            sections.push(format!("## {}\n\n{}", concept.title(), body));
314        } else {
315            sections.push(body.to_string());
316        }
317    }
318
319    sections.join("\n\n")
320}
321
322/// Render the `Syntax` concept as a standalone reference document.
323///
324/// This is the single source for `content/en/syntax.md` (which is a committed,
325/// drift-tested mirror) and for `help syntax`. Each Syntax fragment becomes a
326/// `## <title>` section, in registry order. `LANGUAGE.md` stays hand-authored as
327/// the deeper human reference; a test guards that it still covers this surface.
328pub fn render_syntax_reference() -> String {
329    let mut out = String::from("# kaish Syntax Reference\n");
330    for fragment in FRAGMENTS
331        .iter()
332        .filter(|f| f.concept == Concept::Syntax && f.locale == DEFAULT_LOCALE)
333    {
334        let title = fragment.title.unwrap_or(fragment.key);
335        out.push_str(&format!("\n## {title}\n\n{}\n", fragment.body.trim()));
336    }
337    out
338}
339
340/// Render a single `Syntax` section by its fragment key (e.g. `"collections"`),
341/// or `None` if no such section exists.
342///
343/// This is what backs `help <subsystem>` for a syntax feature big enough to want
344/// its own topic (`help collections`) without hand-writing a second, driftable
345/// copy of the reference text — single-sourced with [`render_syntax_reference`]
346/// and `content/en/syntax.md`. The pattern generalizes to any future
347/// subsystem-sized syntax feature: give its `syntax_section` a memorable key and
348/// it's queryable via `help <key>` for free.
349pub fn render_syntax_section(key: &str) -> Option<String> {
350    let fragment = FRAGMENTS.iter().find(|f| {
351        f.concept == Concept::Syntax && f.locale == DEFAULT_LOCALE && f.key == key
352    })?;
353    let title = fragment.title.unwrap_or(fragment.key);
354    Some(format!("## {title}\n\n{}\n", fragment.body.trim()))
355}
356
357/// A fragment present in English but missing in another locale.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct MissingFragment {
360    /// Concept of the untranslated fragment.
361    pub concept: Concept,
362    /// Key of the untranslated fragment.
363    pub key: &'static str,
364    /// Variant of the untranslated fragment.
365    pub variant: Variant,
366}
367
368/// Report the English fragments that have no translation in `locale`.
369///
370/// English is canonical-complete, so `coverage(DEFAULT_LOCALE)` is always empty.
371/// The runtime fall-back to English is graceful and unmarked; this surfaces gaps
372/// at build/introspection time instead (resolved Q3).
373pub fn coverage(locale: &str) -> Vec<MissingFragment> {
374    FRAGMENTS
375        .iter()
376        .filter(|f| f.locale == DEFAULT_LOCALE)
377        .filter(|f| {
378            !FRAGMENTS.iter().any(|g| {
379                g.locale == locale
380                    && g.concept == f.concept
381                    && g.key == f.key
382                    && g.variant == f.variant
383            })
384        })
385        .map(|f| MissingFragment {
386            concept: f.concept,
387            key: f.key,
388            variant: f.variant,
389        })
390        .collect()
391}
392
393/// Ready-made [`Selector`]s so frontends never hand-build prose.
394///
395/// Wiring these into an embedder's agent instructions / tool description and the
396/// REPL welcome is the next phase (see `docs/composable-help.md`).
397pub struct Recipe;
398
399impl Recipe {
400    /// What an embedder's agent instructions / system prompt use:
401    /// the model, the operating contract, and the builtin index — terse.
402    pub fn agent_onboarding() -> Selector {
403        Selector {
404            concepts: vec![Concept::Model, Concept::Foundations, Concept::Builtins],
405            variants: Vec::new(),
406            audience: Audience::Agent,
407            depth: Depth::Summary,
408            locale: DEFAULT_LOCALE.to_string(),
409            headers: true,
410        }
411    }
412
413    /// The REPL startup welcome: model + the welcome line, human-flavored. Terse
414    /// (no Foundations dump, no section headers) — it's a one-time banner.
415    pub fn repl_welcome() -> Selector {
416        Selector {
417            concepts: vec![Concept::Model],
418            variants: Vec::new(),
419            audience: Audience::Human,
420            depth: Depth::Summary,
421            locale: DEFAULT_LOCALE.to_string(),
422            headers: false,
423        }
424    }
425
426    /// An embedder's `execute` tool description: the operating contract only, terse.
427    pub fn tool_description() -> Selector {
428        Selector {
429            concepts: vec![Concept::Foundations],
430            variants: vec![Variant::Rule, Variant::Contrast],
431            audience: Audience::Agent,
432            depth: Depth::Summary,
433            locale: DEFAULT_LOCALE.to_string(),
434            headers: false,
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn no_content() -> SchemaContent<'static> {
444        SchemaContent::new(&[])
445    }
446
447    #[test]
448    fn agent_onboarding_has_foundations_content() {
449        let out = compose(&Recipe::agent_onboarding(), &no_content());
450        assert!(out.contains("How kaish works"));
451        // A core guarantee must be present.
452        assert!(
453            out.to_lowercase().contains("word"),
454            "expected the no-word-splitting guarantee, got:\n{out}"
455        );
456    }
457
458    /// Three syntax rules an agent session verified live against kaish 0.13 —
459    /// compound-into-pipe, `[ … ]`, bare `yes`/`no` — must reach both
460    /// agent-facing recipes, since those are the surfaces an embedded agent
461    /// actually reads (a kaijutsu session burned a 63k-token tour hitting these
462    /// with no warning in either). A fourth rule, unquoted comma, was verified
463    /// the same way and got its own fragment (`comma-splits-word`) — the
464    /// grammar itself was fixed instead (comma is significant only inside a
465    /// `[...]`/`{...}` literal or pattern; see `docs/LANGUAGE.md`,
466    /// "Construction"), so the fragment was retired rather than kept as a
467    /// warning about behavior that no longer exists.
468    #[test]
469    fn agent_onboarding_covers_the_verified_syntax_gaps() {
470        let out = compose(&Recipe::agent_onboarding(), &no_content());
471        for needle in [
472            "compound statement can't feed a pipe",
473            "is not a command",
474            "are lexer errors",
475        ] {
476            assert!(out.contains(needle), "agent_onboarding missing {needle:?}:\n{out}");
477        }
478    }
479
480    #[test]
481    fn tool_description_covers_the_verified_syntax_gaps() {
482        let out = compose(&Recipe::tool_description(), &no_content());
483        for needle in [
484            "compound statement can't feed a pipe",
485            "is not a command",
486            "are lexer errors",
487        ] {
488            assert!(out.contains(needle), "tool_description missing {needle:?}:\n{out}");
489        }
490    }
491
492    /// Overlay mode is opt-in (see `Concept::Overlay`'s doc comment): neither
493    /// default recipe should pay for a paragraph most embedders never need —
494    /// kaijutsu never enables `--overlay`, and kaibo's read-only sandbox used to
495    /// hand-strip this same paragraph out of `tool_description()` output.
496    #[test]
497    fn overlay_excluded_by_default() {
498        let onboarding = compose(&Recipe::agent_onboarding(), &no_content());
499        let tool_desc = compose(&Recipe::tool_description(), &no_content());
500        for (name, out) in [("agent_onboarding", &onboarding), ("tool_description", &tool_desc)] {
501            assert!(
502                !out.contains("Overlay mode") && !out.contains("kaish-vfs commit"),
503                "{name} must not carry overlay guidance by default:\n{out}"
504            );
505        }
506    }
507
508    /// An embedder that *does* use overlay (kaibo's planned coder workspaces, for
509    /// instance) opts in with one chained call.
510    #[test]
511    fn overlay_can_be_composed_in_with_with_overlay() {
512        let out = compose(&Recipe::agent_onboarding().with_overlay(), &no_content());
513        assert!(out.contains("Overlay mode"), "with_overlay() must add the overlay guidance:\n{out}");
514        assert!(out.contains("kaish-vfs commit"), "overlay guidance should mention kaish-vfs commit:\n{out}");
515        assert!(out.contains("How kaish works"), "with_overlay() must not drop the rest of the spine:\n{out}");
516    }
517
518    /// `without_overlay()` is symmetric with `with_overlay()` — round-tripping
519    /// removes it again, and calling it when overlay was never present is a no-op
520    /// rather than an error.
521    #[test]
522    fn without_overlay_removes_it_and_is_a_noop_when_absent() {
523        let with_it = compose(&Recipe::agent_onboarding().with_overlay(), &no_content());
524        assert!(with_it.contains("Overlay mode"));
525
526        let round_tripped = compose(&Recipe::agent_onboarding().with_overlay().without_overlay(), &no_content());
527        assert!(
528            !round_tripped.contains("Overlay mode"),
529            "without_overlay() must remove it again:\n{round_tripped}"
530        );
531
532        let baseline = compose(&Recipe::agent_onboarding(), &no_content());
533        let noop = compose(&Recipe::agent_onboarding().without_overlay(), &no_content());
534        assert_eq!(baseline, noop, "without_overlay() must be a no-op when overlay was never selected");
535    }
536
537    #[test]
538    fn audience_filters_human_only_from_agent() {
539        let agent = compose(&Recipe::agent_onboarding(), &no_content());
540        let human = compose(&Recipe::repl_welcome(), &no_content());
541        // The REPL welcome line is Human-only and must not leak into the agent blob.
542        assert!(human.contains("exit"), "human welcome should mention exit");
543        assert!(
544            !agent.contains("exit to quit") && !agent.contains("`exit`"),
545            "agent onboarding must not include the human welcome line"
546        );
547    }
548
549    #[test]
550    fn agent_only_fragment_excluded_from_human() {
551        let agent = compose(&Recipe::agent_onboarding(), &no_content());
552        let human = compose(&Recipe::repl_welcome(), &no_content());
553        // The "prefer --json when orchestrating" line is Agent-only.
554        assert!(agent.contains("orchestrat"), "agent blob should carry the agent-only json guidance");
555        assert!(!human.contains("orchestrat"), "agent-only guidance must not appear in human welcome");
556    }
557
558    #[test]
559    fn builtins_concept_pulls_from_generated_content() {
560        let schemas = vec![
561            ToolSchema::new("echo", "Print arguments"),
562            ToolSchema::new("cat", "Read a file"),
563        ];
564        let out = compose(&Recipe::agent_onboarding(), &SchemaContent::new(&schemas));
565        assert!(out.contains("## Builtins"));
566        assert!(out.contains("echo"));
567        assert!(out.contains("cat"));
568    }
569
570    #[test]
571    fn depth_summary_excludes_reference_only_fragments() {
572        let mut sel = Recipe::agent_onboarding();
573        sel.depth = Depth::Summary;
574        let summary = compose(&sel, &no_content());
575        sel.depth = Depth::Reference;
576        let reference = compose(&sel, &no_content());
577        // Reference is a superset: at least as long, and contains an example block.
578        assert!(reference.len() >= summary.len());
579        assert!(reference.contains("```"), "reference depth should include example fragments");
580    }
581
582    #[test]
583    fn onboarding_renders_in_importance_rank_order() {
584        let out = compose(&Recipe::agent_onboarding(), &no_content());
585        let nws = out.find("No word splitting").expect("has no-word-splitting");
586        let fail = out.find("Fail loud").expect("has crash-not-corrupt");
587        assert!(
588            nws < fail,
589            "the most-important rule (no-word-splitting, rank 0) must lead:\n{out}"
590        );
591        // Rank overrides registry position: structured-substitution (rank 4)
592        // appears *before* structured-output (rank 8) even though the registry
593        // lists structured-output first — proof the rank sort is doing work.
594        let subst = out.find("carries structured data").expect("has structured-substitution");
595        let output = out.find("Structured output").expect("has structured-output");
596        assert!(
597            subst < output,
598            "rank must reorder ahead of registry position:\n{out}"
599        );
600    }
601
602    /// The always-on onboarding block (the fragment spine, without the
603    /// embedder-supplied builtin index) has a budget: it is composed in
604    /// importance order and must stay lean so the critical rules survive
605    /// skimming/truncation. Bumping this ceiling should be a deliberate choice —
606    /// prefer moving verbose, low-rank content into a `help` topic and pointing
607    /// at it from the tail.
608    #[test]
609    fn onboarding_spine_stays_within_budget() {
610        const BUDGET: usize = 3500;
611        let out = compose(&Recipe::agent_onboarding(), &no_content());
612        assert!(
613            out.len() <= BUDGET,
614            "always-on onboarding spine is {} chars (budget {BUDGET}) — trim or defer \
615             low-rank content to a help topic:\n{out}",
616            out.len()
617        );
618    }
619
620    #[test]
621    fn repl_welcome_intro_precedes_help_line() {
622        let out = compose(&Recipe::repl_welcome(), &no_content());
623        let intro = out.find("Bourne-like").expect("has intro");
624        let help_line = out.find("Type `help`").expect("has welcome line");
625        assert!(intro < help_line, "intro should precede the help/exit line:\n{out}");
626    }
627
628    #[test]
629    fn repl_welcome_is_headerless_and_terse() {
630        let out = compose(&Recipe::repl_welcome(), &no_content());
631        assert!(!out.contains("##"), "REPL banner must not carry markdown headers:\n{out}");
632        assert!(out.contains("help"), "welcome should point at help");
633        assert!(out.contains("exit"), "welcome should mention exit");
634    }
635
636    #[test]
637    fn agent_onboarding_renders_section_headers() {
638        let out = compose(&Recipe::agent_onboarding(), &no_content());
639        assert!(out.contains("## "), "markdown clients want section headers:\n{out}");
640    }
641
642    #[test]
643    fn syntax_md_matches_fragments() {
644        assert_eq!(
645            crate::content::SYNTAX,
646            render_syntax_reference(),
647            "content/en/syntax.md is stale — run \
648             `cargo run -p kaish-help --example regen_syntax`"
649        );
650    }
651
652    #[test]
653    fn syntax_reference_covers_core_topics() {
654        let out = render_syntax_reference();
655        for needle in ["## Variables", "## Quoting", "## Command Substitution", "## Functions"] {
656            assert!(out.contains(needle), "syntax reference missing {needle}");
657        }
658    }
659
660    #[test]
661    fn language_md_still_covers_the_syntax_surface() {
662        // LANGUAGE.md stays hand-authored (deeper human reference); guard that it
663        // hasn't lost coverage of the syntax topics the fragments single-source.
664        let lang = std::fs::read_to_string(concat!(
665            env!("CARGO_MANIFEST_DIR"),
666            "/../../docs/LANGUAGE.md"
667        ))
668        .expect("read docs/LANGUAGE.md");
669        for needle in [
670            "Quoting",
671            "Parameter Expansion",
672            "Pipes & Redirects",
673            "Command Substitution",
674            "Arithmetic",
675            "Functions",
676            "Control Flow",
677            "Test Expressions",
678        ] {
679            assert!(lang.contains(needle), "LANGUAGE.md no longer covers: {needle}");
680        }
681    }
682
683    #[test]
684    fn coverage_english_is_complete() {
685        assert!(
686            coverage(DEFAULT_LOCALE).is_empty(),
687            "English is canonical-complete by definition"
688        );
689    }
690
691    #[test]
692    fn coverage_reports_untranslated_locale() {
693        // No Japanese fragments exist yet, so every English slot is missing.
694        let missing = coverage("ja");
695        assert!(!missing.is_empty(), "ja has no fragments, so all slots are missing");
696        let english_count = FRAGMENTS.iter().filter(|f| f.locale == DEFAULT_LOCALE).count();
697        assert_eq!(missing.len(), english_count);
698    }
699}