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}