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 -l --json | jq -r '.[].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 rules an embedded agent hits early must reach both agent-facing
459    /// recipes: a compound statement is a pipeline stage and buffers, `[ … ]`
460    /// is not a command, and only lowercase `true`/`false` are booleans. These recipes
461    /// are the surfaces an embedded agent actually reads, so a rule missing
462    /// here is a rule it meets for the first time as a failure.
463    ///
464    /// The needles are substrings of fragment bodies. When a fragment is
465    /// reworded, move the needle rather than dropping it — the guarantee is
466    /// that the *rule* is covered, not that a phrase survives.
467    #[test]
468    fn agent_onboarding_covers_the_verified_syntax_gaps() {
469        let out = compose(&Recipe::agent_onboarding(), &no_content());
470        for needle in [
471            "compound statement is a pipeline stage",
472            "is not a command",
473            "lowercase `true`/`false` are booleans",
474        ] {
475            assert!(out.contains(needle), "agent_onboarding missing {needle:?}:\n{out}");
476        }
477    }
478
479    #[test]
480    fn tool_description_covers_the_verified_syntax_gaps() {
481        let out = compose(&Recipe::tool_description(), &no_content());
482        for needle in [
483            "compound statement is a pipeline stage",
484            "is not a command",
485            "lowercase `true`/`false` are booleans",
486        ] {
487            assert!(out.contains(needle), "tool_description missing {needle:?}:\n{out}");
488        }
489    }
490
491    /// Overlay mode is opt-in (see `Concept::Overlay`'s doc comment): neither
492    /// default recipe should pay for a paragraph most embedders never need —
493    /// kaijutsu never enables `--overlay`, and kaibo's read-only sandbox used to
494    /// hand-strip this same paragraph out of `tool_description()` output.
495    #[test]
496    fn overlay_excluded_by_default() {
497        let onboarding = compose(&Recipe::agent_onboarding(), &no_content());
498        let tool_desc = compose(&Recipe::tool_description(), &no_content());
499        for (name, out) in [("agent_onboarding", &onboarding), ("tool_description", &tool_desc)] {
500            assert!(
501                !out.contains("Overlay mode") && !out.contains("kaish-vfs commit"),
502                "{name} must not carry overlay guidance by default:\n{out}"
503            );
504        }
505    }
506
507    /// An embedder that *does* use overlay (kaibo's planned coder workspaces, for
508    /// instance) opts in with one chained call.
509    #[test]
510    fn overlay_can_be_composed_in_with_with_overlay() {
511        let out = compose(&Recipe::agent_onboarding().with_overlay(), &no_content());
512        assert!(out.contains("Overlay mode"), "with_overlay() must add the overlay guidance:\n{out}");
513        assert!(out.contains("kaish-vfs commit"), "overlay guidance should mention kaish-vfs commit:\n{out}");
514        assert!(out.contains("How kaish works"), "with_overlay() must not drop the rest of the spine:\n{out}");
515    }
516
517    /// `without_overlay()` is symmetric with `with_overlay()` — round-tripping
518    /// removes it again, and calling it when overlay was never present is a no-op
519    /// rather than an error.
520    #[test]
521    fn without_overlay_removes_it_and_is_a_noop_when_absent() {
522        let with_it = compose(&Recipe::agent_onboarding().with_overlay(), &no_content());
523        assert!(with_it.contains("Overlay mode"));
524
525        let round_tripped = compose(&Recipe::agent_onboarding().with_overlay().without_overlay(), &no_content());
526        assert!(
527            !round_tripped.contains("Overlay mode"),
528            "without_overlay() must remove it again:\n{round_tripped}"
529        );
530
531        let baseline = compose(&Recipe::agent_onboarding(), &no_content());
532        let noop = compose(&Recipe::agent_onboarding().without_overlay(), &no_content());
533        assert_eq!(baseline, noop, "without_overlay() must be a no-op when overlay was never selected");
534    }
535
536    #[test]
537    fn audience_filters_human_only_from_agent() {
538        let agent = compose(&Recipe::agent_onboarding(), &no_content());
539        let human = compose(&Recipe::repl_welcome(), &no_content());
540        // The REPL welcome line is Human-only and must not leak into the agent blob.
541        assert!(human.contains("exit"), "human welcome should mention exit");
542        assert!(
543            !agent.contains("exit to quit") && !agent.contains("`exit`"),
544            "agent onboarding must not include the human welcome line"
545        );
546    }
547
548    #[test]
549    fn agent_only_fragment_excluded_from_human() {
550        let agent = compose(&Recipe::agent_onboarding(), &no_content());
551        let human = compose(&Recipe::repl_welcome(), &no_content());
552        // The "prefer --json when orchestrating" line is Agent-only.
553        assert!(agent.contains("orchestrat"), "agent blob should carry the agent-only json guidance");
554        assert!(!human.contains("orchestrat"), "agent-only guidance must not appear in human welcome");
555    }
556
557    #[test]
558    fn builtins_concept_pulls_from_generated_content() {
559        let schemas = vec![
560            ToolSchema::new("echo", "Print arguments"),
561            ToolSchema::new("cat", "Read a file"),
562        ];
563        let out = compose(&Recipe::agent_onboarding(), &SchemaContent::new(&schemas));
564        assert!(out.contains("## Builtins"));
565        assert!(out.contains("echo"));
566        assert!(out.contains("cat"));
567    }
568
569    #[test]
570    fn depth_summary_excludes_reference_only_fragments() {
571        let mut sel = Recipe::agent_onboarding();
572        sel.depth = Depth::Summary;
573        let summary = compose(&sel, &no_content());
574        sel.depth = Depth::Reference;
575        let reference = compose(&sel, &no_content());
576        // Reference is a superset: at least as long, and contains an example block.
577        assert!(reference.len() >= summary.len());
578        assert!(reference.contains("```"), "reference depth should include example fragments");
579    }
580
581    #[test]
582    fn onboarding_renders_in_importance_rank_order() {
583        let out = compose(&Recipe::agent_onboarding(), &no_content());
584        let nws = out.find("No word splitting").expect("has no-word-splitting");
585        let fail = out.find("Fail loud").expect("has crash-not-corrupt");
586        assert!(
587            nws < fail,
588            "the most-important rule (no-word-splitting, rank 0) must lead:\n{out}"
589        );
590        // Rank overrides registry position: structured-substitution (rank 4)
591        // appears *before* structured-output (rank 8) even though the registry
592        // lists structured-output first — proof the rank sort is doing work.
593        let subst = out.find("carries structured data").expect("has structured-substitution");
594        let output = out.find("Structured output").expect("has structured-output");
595        assert!(
596            subst < output,
597            "rank must reorder ahead of registry position:\n{out}"
598        );
599    }
600
601    /// The always-on onboarding block (the fragment spine, without the
602    /// embedder-supplied builtin index) has a budget: it is composed in
603    /// importance order and must stay lean so the critical rules survive
604    /// skimming/truncation. Bumping this ceiling should be a deliberate choice —
605    /// prefer moving verbose, low-rank content into a `help` topic and pointing
606    /// at it from the tail.
607    #[test]
608    fn onboarding_spine_stays_within_budget() {
609        const BUDGET: usize = 3500;
610        let out = compose(&Recipe::agent_onboarding(), &no_content());
611        assert!(
612            out.len() <= BUDGET,
613            "always-on onboarding spine is {} chars (budget {BUDGET}) — trim or defer \
614             low-rank content to a help topic:\n{out}",
615            out.len()
616        );
617    }
618
619    #[test]
620    fn repl_welcome_intro_precedes_help_line() {
621        let out = compose(&Recipe::repl_welcome(), &no_content());
622        let intro = out.find("Bourne-like").expect("has intro");
623        let help_line = out.find("Type `help`").expect("has welcome line");
624        assert!(intro < help_line, "intro should precede the help/exit line:\n{out}");
625    }
626
627    #[test]
628    fn repl_welcome_is_headerless_and_terse() {
629        let out = compose(&Recipe::repl_welcome(), &no_content());
630        assert!(!out.contains("##"), "REPL banner must not carry markdown headers:\n{out}");
631        assert!(out.contains("help"), "welcome should point at help");
632        assert!(out.contains("exit"), "welcome should mention exit");
633    }
634
635    #[test]
636    fn agent_onboarding_renders_section_headers() {
637        let out = compose(&Recipe::agent_onboarding(), &no_content());
638        assert!(out.contains("## "), "markdown clients want section headers:\n{out}");
639    }
640
641    #[test]
642    fn syntax_md_matches_fragments() {
643        assert_eq!(
644            crate::content::SYNTAX,
645            render_syntax_reference(),
646            "content/en/syntax.md is stale — run \
647             `cargo run -p kaish-help --example regen_syntax`"
648        );
649    }
650
651    #[test]
652    fn syntax_reference_covers_core_topics() {
653        let out = render_syntax_reference();
654        for needle in ["## Variables", "## Quoting", "## Command Substitution", "## Functions"] {
655            assert!(out.contains(needle), "syntax reference missing {needle}");
656        }
657    }
658
659    #[test]
660    fn language_md_still_covers_the_syntax_surface() {
661        // LANGUAGE.md stays hand-authored (deeper human reference); guard that it
662        // hasn't lost coverage of the syntax topics the fragments single-source.
663        let lang = std::fs::read_to_string(concat!(
664            env!("CARGO_MANIFEST_DIR"),
665            "/../../docs/LANGUAGE.md"
666        ))
667        .expect("read docs/LANGUAGE.md");
668        for needle in [
669            "Quoting",
670            "Parameter Expansion",
671            "Pipes & Redirects",
672            "Command Substitution",
673            "Arithmetic",
674            "Functions",
675            "Control Flow",
676            "Test Expressions",
677        ] {
678            assert!(lang.contains(needle), "LANGUAGE.md no longer covers: {needle}");
679        }
680    }
681
682    #[test]
683    fn coverage_english_is_complete() {
684        assert!(
685            coverage(DEFAULT_LOCALE).is_empty(),
686            "English is canonical-complete by definition"
687        );
688    }
689
690    #[test]
691    fn coverage_reports_untranslated_locale() {
692        // No Japanese fragments exist yet, so every English slot is missing.
693        let missing = coverage("ja");
694        assert!(!missing.is_empty(), "ja has no fragments, so all slots are missing");
695        let english_count = FRAGMENTS.iter().filter(|f| f.locale == DEFAULT_LOCALE).count();
696        assert_eq!(missing.len(), english_count);
697    }
698}