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    // Capabilities — deferred until the capability-feature split gives it a body.
33}
34
35impl Concept {
36    /// Human-readable section title used when composing.
37    pub fn title(&self) -> &'static str {
38        match self {
39            Self::Model => "About kaish",
40            Self::Syntax => "Syntax",
41            Self::Foundations => "How kaish works",
42            Self::Builtins => "Builtins",
43            Self::Limits => "Limitations",
44        }
45    }
46}
47
48/// The "how it's said" — variations of one idea, used to reinforce.
49///
50/// There is deliberately no Style/Guidance variant: idiomatic best-practice
51/// ("prefer `--json`") is foundational *content* (the [`Concept::Foundations`]
52/// concept), not a rendering. See `docs/composable-help.md` (resolved Q2).
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum Variant {
55    /// Terse imperative ("use `--json` for structured output").
56    Rule,
57    /// Worked snippet (`ls --json | jq '.[].name'`).
58    Example,
59    /// How bash differs ("bash makes you parse `ls` text").
60    Contrast,
61    /// Why kaish chose this ("every builtin emits structured data").
62    Rationale,
63}
64
65impl Variant {
66    /// Stable order within a concept/key: rule, then example, contrast, rationale.
67    fn order(&self) -> u8 {
68        match self {
69            Self::Rule => 0,
70            Self::Example => 1,
71            Self::Contrast => 2,
72            Self::Rationale => 3,
73        }
74    }
75}
76
77/// Who the rendered content is for. A *lens*, not a fork: most fragments are
78/// shared (`audience: None`); the rare divergence is `Some(_)`.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum Audience {
81    /// An agent driving kaish (embedded) — terse, behavior-focused.
82    Agent,
83    /// A human at the REPL — welcome + discoverability.
84    Human,
85}
86
87/// How much to include. `Summary` is the always-on core; `Reference` adds detail.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
89pub enum Depth {
90    /// Just the load-bearing material.
91    Summary,
92    /// Everything, including examples and rationale.
93    Reference,
94}
95
96/// Default (and canonical-complete) content locale.
97pub const DEFAULT_LOCALE: &str = "en";
98
99/// The importance rank of an unranked fragment: sorts last, so a fragment with
100/// no explicit rank keeps its registry position relative to other unranked
101/// fragments. Only the always-on onboarding spine assigns explicit ranks.
102pub const UNRANKED: u8 = u8::MAX;
103
104/// A unit of content, addressed by (concept, key, variant, locale).
105pub struct Fragment {
106    /// Concept this fragment belongs to.
107    pub concept: Concept,
108    /// Sub-topic within the concept, e.g. `"no-word-splitting"`.
109    pub key: &'static str,
110    /// How this fragment renders its idea.
111    pub variant: Variant,
112    /// `Summary` fragments always show; `Reference` only at reference depth.
113    pub depth: Depth,
114    /// BCP-47 locale tag. English (`"en"`) is the canonical-complete base.
115    pub locale: &'static str,
116    /// `None` = shared (default); `Some(_)` = audience-specific divergence.
117    pub audience: Option<Audience>,
118    /// Importance rank for the always-on onboarding block: `0` is the most
119    /// important, and composition renders fragments **in ascending rank** so the
120    /// client model meets the critical rules first even under skimming or
121    /// truncation. Fragments at [`UNRANKED`] (the default) keep registry order.
122    /// The rank is a property of the `(concept, key, variant)` slot, so it is
123    /// locale-agnostic — a translation of a fragment inherits the same rank.
124    pub rank: u8,
125    /// Optional section heading for reference rendering (e.g. `"Quoting"`).
126    /// `None` for inline fragments (the Foundations spine renders under its
127    /// concept header instead). Used by [`render_syntax_reference`].
128    pub title: Option<&'static str>,
129    /// Markdown body.
130    pub body: &'static str,
131}
132
133impl Fragment {
134    /// Attach an importance `rank` (0 = most important) to a fragment, for the
135    /// always-on onboarding block. `const` so it composes in the static registry:
136    /// `en(...).ranked(2)`.
137    pub const fn ranked(self, rank: u8) -> Fragment {
138        Fragment { rank, ..self }
139    }
140}
141
142/// What to compose. Build one directly, or use a [`Recipe`].
143pub struct Selector {
144    /// Concepts to include, in render order.
145    pub concepts: Vec<Concept>,
146    /// Variants to include; empty means all.
147    pub variants: Vec<Variant>,
148    /// Audience lens.
149    pub audience: Audience,
150    /// How much detail.
151    pub depth: Depth,
152    /// Requested locale; falls back to [`DEFAULT_LOCALE`] per slot.
153    pub locale: String,
154    /// Emit `## <concept title>` section headers. Markdown-rendering clients want
155    /// them; a plain-terminal REPL banner does not.
156    pub headers: bool,
157}
158
159/// Live, schema-derived content the static fragments can't hold.
160///
161/// The kernel implements this (it owns the tool registry); this crate stays free
162/// of the registry. [`SchemaContent`] is the standard implementation.
163pub trait GeneratedContent {
164    /// `(name, one-line description)` for every available builtin, in list order.
165    fn builtin_index(&self) -> Vec<(String, String)>;
166    /// The schema skeleton for one tool, or `None` if it isn't registered.
167    fn tool_help(&self, name: &str) -> Option<String>;
168}
169
170/// [`GeneratedContent`] backed by a slice of tool schemas.
171pub struct SchemaContent<'a> {
172    schemas: &'a [ToolSchema],
173}
174
175impl<'a> SchemaContent<'a> {
176    /// Wrap a slice of schemas. Pass `&[]` when a recipe needs no generated content.
177    pub fn new(schemas: &'a [ToolSchema]) -> Self {
178        Self { schemas }
179    }
180}
181
182impl GeneratedContent for SchemaContent<'_> {
183    fn builtin_index(&self) -> Vec<(String, String)> {
184        self.schemas
185            .iter()
186            .map(|s| (s.name.clone(), s.description.clone()))
187            .collect()
188    }
189
190    fn tool_help(&self, name: &str) -> Option<String> {
191        tool_help(name, self.schemas)
192    }
193}
194
195/// Whether a fragment passes the selector's audience/depth/variant filters.
196/// (Locale is resolved per slot afterwards, not here.)
197fn applicable(fragment: &Fragment, selector: &Selector) -> bool {
198    let variant_ok = selector.variants.is_empty() || selector.variants.contains(&fragment.variant);
199    let depth_ok = fragment.depth == Depth::Summary || selector.depth == Depth::Reference;
200    let audience_ok = fragment.audience.is_none_or(|a| a == selector.audience);
201    variant_ok && depth_ok && audience_ok
202}
203
204/// Choose the fragments for one concept: filter, preserve **registry order** (it's
205/// author-controlled and pedagogical — not key-sorted), and resolve each
206/// (key, variant) slot to the requested locale, falling back to English.
207fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
208    // Slot order = first appearance in the registry; `chosen` holds the best
209    // locale match per slot (replacing in place keeps the slot's position).
210    let mut order: Vec<(&str, u8)> = Vec::new();
211    let mut chosen: HashMap<(&str, u8), &Fragment> = HashMap::new();
212
213    for fragment in FRAGMENTS
214        .iter()
215        .filter(|f| f.concept == concept && applicable(f, selector))
216    {
217        let slot = (fragment.key, fragment.variant.order());
218        match chosen.get(&slot) {
219            None => {
220                order.push(slot);
221                chosen.insert(slot, fragment);
222            }
223            // Prefer the requested locale; otherwise keep what we have (English
224            // base, by construction of the registry). Position is unchanged.
225            Some(existing) => {
226                if fragment.locale == selector.locale && existing.locale != selector.locale {
227                    chosen.insert(slot, fragment);
228                }
229            }
230        }
231    }
232
233    let mut result: Vec<&Fragment> = order
234        .iter()
235        .filter_map(|slot| chosen.get(slot).copied())
236        .collect();
237    // Render in importance order: ascending rank, ties preserving registry
238    // position (a stable sort). Unranked fragments (`UNRANKED`) sort last and so
239    // keep their registry order among themselves — a no-op for any concept whose
240    // fragments are all unranked (Syntax, Model), which keeps `syntax.md` stable.
241    result.sort_by_key(|f| f.rank);
242    result
243}
244
245/// Compose canonical kaish guidance into a single markdown document.
246///
247/// Markdown is the only render target for now (resolved Q4, YAGNI). Concepts are
248/// rendered in selector order under `##` headers; the `Builtins` concept pulls its
249/// list from `generated`.
250pub fn compose(selector: &Selector, generated: &dyn GeneratedContent) -> String {
251    let mut sections: Vec<String> = Vec::new();
252
253    for &concept in &selector.concepts {
254        let mut body = String::new();
255
256        if concept == Concept::Builtins {
257            let index = generated.builtin_index();
258            if index.is_empty() {
259                continue;
260            }
261            let width = index.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
262            for (name, desc) in index {
263                body.push_str(&format!("  {name:width$}  {desc}\n"));
264            }
265        } else {
266            let fragments = select_for_concept(concept, selector);
267            if fragments.is_empty() {
268                continue;
269            }
270            for (i, fragment) in fragments.iter().enumerate() {
271                if i > 0 {
272                    body.push('\n');
273                }
274                body.push_str(fragment.body.trim_end());
275                body.push('\n');
276            }
277        }
278
279        let body = body.trim_end();
280        if selector.headers {
281            sections.push(format!("## {}\n\n{}", concept.title(), body));
282        } else {
283            sections.push(body.to_string());
284        }
285    }
286
287    sections.join("\n\n")
288}
289
290/// Render the `Syntax` concept as a standalone reference document.
291///
292/// This is the single source for `content/en/syntax.md` (which is a committed,
293/// drift-tested mirror) and for `help syntax`. Each Syntax fragment becomes a
294/// `## <title>` section, in registry order. `LANGUAGE.md` stays hand-authored as
295/// the deeper human reference; a test guards that it still covers this surface.
296pub fn render_syntax_reference() -> String {
297    let mut out = String::from("# kaish Syntax Reference\n");
298    for fragment in FRAGMENTS
299        .iter()
300        .filter(|f| f.concept == Concept::Syntax && f.locale == DEFAULT_LOCALE)
301    {
302        let title = fragment.title.unwrap_or(fragment.key);
303        out.push_str(&format!("\n## {title}\n\n{}\n", fragment.body.trim()));
304    }
305    out
306}
307
308/// Render a single `Syntax` section by its fragment key (e.g. `"collections"`),
309/// or `None` if no such section exists.
310///
311/// This is what backs `help <subsystem>` for a syntax feature big enough to want
312/// its own topic (`help collections`) without hand-writing a second, driftable
313/// copy of the reference text — single-sourced with [`render_syntax_reference`]
314/// and `content/en/syntax.md`. The pattern generalizes to any future
315/// subsystem-sized syntax feature: give its `syntax_section` a memorable key and
316/// it's queryable via `help <key>` for free.
317pub fn render_syntax_section(key: &str) -> Option<String> {
318    let fragment = FRAGMENTS.iter().find(|f| {
319        f.concept == Concept::Syntax && f.locale == DEFAULT_LOCALE && f.key == key
320    })?;
321    let title = fragment.title.unwrap_or(fragment.key);
322    Some(format!("## {title}\n\n{}\n", fragment.body.trim()))
323}
324
325/// A fragment present in English but missing in another locale.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct MissingFragment {
328    /// Concept of the untranslated fragment.
329    pub concept: Concept,
330    /// Key of the untranslated fragment.
331    pub key: &'static str,
332    /// Variant of the untranslated fragment.
333    pub variant: Variant,
334}
335
336/// Report the English fragments that have no translation in `locale`.
337///
338/// English is canonical-complete, so `coverage(DEFAULT_LOCALE)` is always empty.
339/// The runtime fall-back to English is graceful and unmarked; this surfaces gaps
340/// at build/introspection time instead (resolved Q3).
341pub fn coverage(locale: &str) -> Vec<MissingFragment> {
342    FRAGMENTS
343        .iter()
344        .filter(|f| f.locale == DEFAULT_LOCALE)
345        .filter(|f| {
346            !FRAGMENTS.iter().any(|g| {
347                g.locale == locale
348                    && g.concept == f.concept
349                    && g.key == f.key
350                    && g.variant == f.variant
351            })
352        })
353        .map(|f| MissingFragment {
354            concept: f.concept,
355            key: f.key,
356            variant: f.variant,
357        })
358        .collect()
359}
360
361/// Ready-made [`Selector`]s so frontends never hand-build prose.
362///
363/// Wiring these into an embedder's agent instructions / tool description and the
364/// REPL welcome is the next phase (see `docs/composable-help.md`).
365pub struct Recipe;
366
367impl Recipe {
368    /// What an embedder's agent instructions / system prompt use:
369    /// the model, the operating contract, and the builtin index — terse.
370    pub fn agent_onboarding() -> Selector {
371        Selector {
372            concepts: vec![Concept::Model, Concept::Foundations, Concept::Builtins],
373            variants: Vec::new(),
374            audience: Audience::Agent,
375            depth: Depth::Summary,
376            locale: DEFAULT_LOCALE.to_string(),
377            headers: true,
378        }
379    }
380
381    /// The REPL startup welcome: model + the welcome line, human-flavored. Terse
382    /// (no Foundations dump, no section headers) — it's a one-time banner.
383    pub fn repl_welcome() -> Selector {
384        Selector {
385            concepts: vec![Concept::Model],
386            variants: Vec::new(),
387            audience: Audience::Human,
388            depth: Depth::Summary,
389            locale: DEFAULT_LOCALE.to_string(),
390            headers: false,
391        }
392    }
393
394    /// An embedder's `execute` tool description: the operating contract only, terse.
395    pub fn tool_description() -> Selector {
396        Selector {
397            concepts: vec![Concept::Foundations],
398            variants: vec![Variant::Rule, Variant::Contrast],
399            audience: Audience::Agent,
400            depth: Depth::Summary,
401            locale: DEFAULT_LOCALE.to_string(),
402            headers: false,
403        }
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn no_content() -> SchemaContent<'static> {
412        SchemaContent::new(&[])
413    }
414
415    #[test]
416    fn agent_onboarding_has_foundations_content() {
417        let out = compose(&Recipe::agent_onboarding(), &no_content());
418        assert!(out.contains("How kaish works"));
419        // A core guarantee must be present.
420        assert!(
421            out.to_lowercase().contains("word"),
422            "expected the no-word-splitting guarantee, got:\n{out}"
423        );
424    }
425
426    #[test]
427    fn audience_filters_human_only_from_agent() {
428        let agent = compose(&Recipe::agent_onboarding(), &no_content());
429        let human = compose(&Recipe::repl_welcome(), &no_content());
430        // The REPL welcome line is Human-only and must not leak into the agent blob.
431        assert!(human.contains("exit"), "human welcome should mention exit");
432        assert!(
433            !agent.contains("exit to quit") && !agent.contains("`exit`"),
434            "agent onboarding must not include the human welcome line"
435        );
436    }
437
438    #[test]
439    fn agent_only_fragment_excluded_from_human() {
440        let agent = compose(&Recipe::agent_onboarding(), &no_content());
441        let human = compose(&Recipe::repl_welcome(), &no_content());
442        // The "prefer --json when orchestrating" line is Agent-only.
443        assert!(agent.contains("orchestrat"), "agent blob should carry the agent-only json guidance");
444        assert!(!human.contains("orchestrat"), "agent-only guidance must not appear in human welcome");
445    }
446
447    #[test]
448    fn builtins_concept_pulls_from_generated_content() {
449        let schemas = vec![
450            ToolSchema::new("echo", "Print arguments"),
451            ToolSchema::new("cat", "Read a file"),
452        ];
453        let out = compose(&Recipe::agent_onboarding(), &SchemaContent::new(&schemas));
454        assert!(out.contains("## Builtins"));
455        assert!(out.contains("echo"));
456        assert!(out.contains("cat"));
457    }
458
459    #[test]
460    fn depth_summary_excludes_reference_only_fragments() {
461        let mut sel = Recipe::agent_onboarding();
462        sel.depth = Depth::Summary;
463        let summary = compose(&sel, &no_content());
464        sel.depth = Depth::Reference;
465        let reference = compose(&sel, &no_content());
466        // Reference is a superset: at least as long, and contains an example block.
467        assert!(reference.len() >= summary.len());
468        assert!(reference.contains("```"), "reference depth should include example fragments");
469    }
470
471    #[test]
472    fn onboarding_renders_in_importance_rank_order() {
473        let out = compose(&Recipe::agent_onboarding(), &no_content());
474        let nws = out.find("No word splitting").expect("has no-word-splitting");
475        let fail = out.find("Fail loud").expect("has crash-not-corrupt");
476        assert!(
477            nws < fail,
478            "the most-important rule (no-word-splitting, rank 0) must lead:\n{out}"
479        );
480        // Rank overrides registry position: structured-substitution (rank 2)
481        // appears *before* structured-output (rank 5) even though the registry
482        // lists structured-output first — proof the rank sort is doing work.
483        let subst = out.find("carries structured data").expect("has structured-substitution");
484        let output = out.find("Structured output").expect("has structured-output");
485        assert!(
486            subst < output,
487            "rank must reorder ahead of registry position:\n{out}"
488        );
489    }
490
491    /// The always-on onboarding block (the fragment spine, without the
492    /// embedder-supplied builtin index) has a budget: it is composed in
493    /// importance order and must stay lean so the critical rules survive
494    /// skimming/truncation. Bumping this ceiling should be a deliberate choice —
495    /// prefer moving verbose, low-rank content into a `help` topic and pointing
496    /// at it from the tail.
497    #[test]
498    fn onboarding_spine_stays_within_budget() {
499        const BUDGET: usize = 3500;
500        let out = compose(&Recipe::agent_onboarding(), &no_content());
501        assert!(
502            out.len() <= BUDGET,
503            "always-on onboarding spine is {} chars (budget {BUDGET}) — trim or defer \
504             low-rank content to a help topic:\n{out}",
505            out.len()
506        );
507    }
508
509    #[test]
510    fn repl_welcome_intro_precedes_help_line() {
511        let out = compose(&Recipe::repl_welcome(), &no_content());
512        let intro = out.find("Bourne-like").expect("has intro");
513        let help_line = out.find("Type `help`").expect("has welcome line");
514        assert!(intro < help_line, "intro should precede the help/exit line:\n{out}");
515    }
516
517    #[test]
518    fn repl_welcome_is_headerless_and_terse() {
519        let out = compose(&Recipe::repl_welcome(), &no_content());
520        assert!(!out.contains("##"), "REPL banner must not carry markdown headers:\n{out}");
521        assert!(out.contains("help"), "welcome should point at help");
522        assert!(out.contains("exit"), "welcome should mention exit");
523    }
524
525    #[test]
526    fn agent_onboarding_renders_section_headers() {
527        let out = compose(&Recipe::agent_onboarding(), &no_content());
528        assert!(out.contains("## "), "markdown clients want section headers:\n{out}");
529    }
530
531    #[test]
532    fn syntax_md_matches_fragments() {
533        assert_eq!(
534            crate::content::SYNTAX,
535            render_syntax_reference(),
536            "content/en/syntax.md is stale — run \
537             `cargo run -p kaish-help --example regen_syntax`"
538        );
539    }
540
541    #[test]
542    fn syntax_reference_covers_core_topics() {
543        let out = render_syntax_reference();
544        for needle in ["## Variables", "## Quoting", "## Command Substitution", "## Functions"] {
545            assert!(out.contains(needle), "syntax reference missing {needle}");
546        }
547    }
548
549    #[test]
550    fn language_md_still_covers_the_syntax_surface() {
551        // LANGUAGE.md stays hand-authored (deeper human reference); guard that it
552        // hasn't lost coverage of the syntax topics the fragments single-source.
553        let lang = std::fs::read_to_string(concat!(
554            env!("CARGO_MANIFEST_DIR"),
555            "/../../docs/LANGUAGE.md"
556        ))
557        .expect("read docs/LANGUAGE.md");
558        for needle in [
559            "Quoting",
560            "Parameter Expansion",
561            "Pipes & Redirects",
562            "Command Substitution",
563            "Arithmetic",
564            "Functions",
565            "Control Flow",
566            "Test Expressions",
567        ] {
568            assert!(lang.contains(needle), "LANGUAGE.md no longer covers: {needle}");
569        }
570    }
571
572    #[test]
573    fn coverage_english_is_complete() {
574        assert!(
575            coverage(DEFAULT_LOCALE).is_empty(),
576            "English is canonical-complete by definition"
577        );
578    }
579
580    #[test]
581    fn coverage_reports_untranslated_locale() {
582        // No Japanese fragments exist yet, so every English slot is missing.
583        let missing = coverage("ja");
584        assert!(!missing.is_empty(), "ja has no fragments, so all slots are missing");
585        let english_count = FRAGMENTS.iter().filter(|f| f.locale == DEFAULT_LOCALE).count();
586        assert_eq!(missing.len(), english_count);
587    }
588}