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 (MCP / 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/// A unit of content, addressed by (concept, key, variant, locale).
100pub struct Fragment {
101    /// Concept this fragment belongs to.
102    pub concept: Concept,
103    /// Sub-topic within the concept, e.g. `"no-word-splitting"`.
104    pub key: &'static str,
105    /// How this fragment renders its idea.
106    pub variant: Variant,
107    /// `Summary` fragments always show; `Reference` only at reference depth.
108    pub depth: Depth,
109    /// BCP-47 locale tag. English (`"en"`) is the canonical-complete base.
110    pub locale: &'static str,
111    /// `None` = shared (default); `Some(_)` = audience-specific divergence.
112    pub audience: Option<Audience>,
113    /// Optional section heading for reference rendering (e.g. `"Quoting"`).
114    /// `None` for inline fragments (the Foundations spine renders under its
115    /// concept header instead). Used by [`render_syntax_reference`].
116    pub title: Option<&'static str>,
117    /// Markdown body.
118    pub body: &'static str,
119}
120
121/// What to compose. Build one directly, or use a [`Recipe`].
122pub struct Selector {
123    /// Concepts to include, in render order.
124    pub concepts: Vec<Concept>,
125    /// Variants to include; empty means all.
126    pub variants: Vec<Variant>,
127    /// Audience lens.
128    pub audience: Audience,
129    /// How much detail.
130    pub depth: Depth,
131    /// Requested locale; falls back to [`DEFAULT_LOCALE`] per slot.
132    pub locale: String,
133    /// Emit `## <concept title>` section headers. Markdown-rendering clients want
134    /// them; a plain-terminal REPL banner does not.
135    pub headers: bool,
136}
137
138/// Live, schema-derived content the static fragments can't hold.
139///
140/// The kernel implements this (it owns the tool registry); this crate stays free
141/// of the registry. [`SchemaContent`] is the standard implementation.
142pub trait GeneratedContent {
143    /// `(name, one-line description)` for every available builtin, in list order.
144    fn builtin_index(&self) -> Vec<(String, String)>;
145    /// The schema skeleton for one tool, or `None` if it isn't registered.
146    fn tool_help(&self, name: &str) -> Option<String>;
147}
148
149/// [`GeneratedContent`] backed by a slice of tool schemas.
150pub struct SchemaContent<'a> {
151    schemas: &'a [ToolSchema],
152}
153
154impl<'a> SchemaContent<'a> {
155    /// Wrap a slice of schemas. Pass `&[]` when a recipe needs no generated content.
156    pub fn new(schemas: &'a [ToolSchema]) -> Self {
157        Self { schemas }
158    }
159}
160
161impl GeneratedContent for SchemaContent<'_> {
162    fn builtin_index(&self) -> Vec<(String, String)> {
163        self.schemas
164            .iter()
165            .map(|s| (s.name.clone(), s.description.clone()))
166            .collect()
167    }
168
169    fn tool_help(&self, name: &str) -> Option<String> {
170        tool_help(name, self.schemas)
171    }
172}
173
174/// Whether a fragment passes the selector's audience/depth/variant filters.
175/// (Locale is resolved per slot afterwards, not here.)
176fn applicable(fragment: &Fragment, selector: &Selector) -> bool {
177    let variant_ok = selector.variants.is_empty() || selector.variants.contains(&fragment.variant);
178    let depth_ok = fragment.depth == Depth::Summary || selector.depth == Depth::Reference;
179    let audience_ok = fragment.audience.is_none_or(|a| a == selector.audience);
180    variant_ok && depth_ok && audience_ok
181}
182
183/// Choose the fragments for one concept: filter, preserve **registry order** (it's
184/// author-controlled and pedagogical — not key-sorted), and resolve each
185/// (key, variant) slot to the requested locale, falling back to English.
186fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
187    // Slot order = first appearance in the registry; `chosen` holds the best
188    // locale match per slot (replacing in place keeps the slot's position).
189    let mut order: Vec<(&str, u8)> = Vec::new();
190    let mut chosen: HashMap<(&str, u8), &Fragment> = HashMap::new();
191
192    for fragment in FRAGMENTS
193        .iter()
194        .filter(|f| f.concept == concept && applicable(f, selector))
195    {
196        let slot = (fragment.key, fragment.variant.order());
197        match chosen.get(&slot) {
198            None => {
199                order.push(slot);
200                chosen.insert(slot, fragment);
201            }
202            // Prefer the requested locale; otherwise keep what we have (English
203            // base, by construction of the registry). Position is unchanged.
204            Some(existing) => {
205                if fragment.locale == selector.locale && existing.locale != selector.locale {
206                    chosen.insert(slot, fragment);
207                }
208            }
209        }
210    }
211
212    order
213        .iter()
214        .filter_map(|slot| chosen.get(slot).copied())
215        .collect()
216}
217
218/// Compose canonical kaish guidance into a single markdown document.
219///
220/// Markdown is the only render target for now (resolved Q4, YAGNI). Concepts are
221/// rendered in selector order under `##` headers; the `Builtins` concept pulls its
222/// list from `generated`.
223pub fn compose(selector: &Selector, generated: &dyn GeneratedContent) -> String {
224    let mut sections: Vec<String> = Vec::new();
225
226    for &concept in &selector.concepts {
227        let mut body = String::new();
228
229        if concept == Concept::Builtins {
230            let index = generated.builtin_index();
231            if index.is_empty() {
232                continue;
233            }
234            let width = index.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
235            for (name, desc) in index {
236                body.push_str(&format!("  {name:width$}  {desc}\n"));
237            }
238        } else {
239            let fragments = select_for_concept(concept, selector);
240            if fragments.is_empty() {
241                continue;
242            }
243            for (i, fragment) in fragments.iter().enumerate() {
244                if i > 0 {
245                    body.push('\n');
246                }
247                body.push_str(fragment.body.trim_end());
248                body.push('\n');
249            }
250        }
251
252        let body = body.trim_end();
253        if selector.headers {
254            sections.push(format!("## {}\n\n{}", concept.title(), body));
255        } else {
256            sections.push(body.to_string());
257        }
258    }
259
260    sections.join("\n\n")
261}
262
263/// Render the `Syntax` concept as a standalone reference document.
264///
265/// This is the single source for `content/en/syntax.md` (which is a committed,
266/// drift-tested mirror) and for `help syntax`. Each Syntax fragment becomes a
267/// `## <title>` section, in registry order. `LANGUAGE.md` stays hand-authored as
268/// the deeper human reference; a test guards that it still covers this surface.
269pub fn render_syntax_reference() -> String {
270    let mut out = String::from("# kaish Syntax Reference\n");
271    for fragment in FRAGMENTS
272        .iter()
273        .filter(|f| f.concept == Concept::Syntax && f.locale == DEFAULT_LOCALE)
274    {
275        let title = fragment.title.unwrap_or(fragment.key);
276        out.push_str(&format!("\n## {title}\n\n{}\n", fragment.body.trim()));
277    }
278    out
279}
280
281/// A fragment present in English but missing in another locale.
282#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct MissingFragment {
284    /// Concept of the untranslated fragment.
285    pub concept: Concept,
286    /// Key of the untranslated fragment.
287    pub key: &'static str,
288    /// Variant of the untranslated fragment.
289    pub variant: Variant,
290}
291
292/// Report the English fragments that have no translation in `locale`.
293///
294/// English is canonical-complete, so `coverage(DEFAULT_LOCALE)` is always empty.
295/// The runtime fall-back to English is graceful and unmarked; this surfaces gaps
296/// at build/introspection time instead (resolved Q3).
297pub fn coverage(locale: &str) -> Vec<MissingFragment> {
298    FRAGMENTS
299        .iter()
300        .filter(|f| f.locale == DEFAULT_LOCALE)
301        .filter(|f| {
302            !FRAGMENTS.iter().any(|g| {
303                g.locale == locale
304                    && g.concept == f.concept
305                    && g.key == f.key
306                    && g.variant == f.variant
307            })
308        })
309        .map(|f| MissingFragment {
310            concept: f.concept,
311            key: f.key,
312            variant: f.variant,
313        })
314        .collect()
315}
316
317/// Ready-made [`Selector`]s so frontends never hand-build prose.
318///
319/// Wiring these into the MCP server instructions / tool description and the REPL
320/// welcome is the next phase (see `docs/composable-help.md`).
321pub struct Recipe;
322
323impl Recipe {
324    /// What the MCP `instructions:` field and an embedder's agent system prompt use:
325    /// the model, the operating contract, and the builtin index — terse.
326    pub fn agent_onboarding() -> Selector {
327        Selector {
328            concepts: vec![Concept::Model, Concept::Foundations, Concept::Builtins],
329            variants: Vec::new(),
330            audience: Audience::Agent,
331            depth: Depth::Summary,
332            locale: DEFAULT_LOCALE.to_string(),
333            headers: true,
334        }
335    }
336
337    /// The REPL startup welcome: model + the welcome line, human-flavored. Terse
338    /// (no Foundations dump, no section headers) — it's a one-time banner.
339    pub fn repl_welcome() -> Selector {
340        Selector {
341            concepts: vec![Concept::Model],
342            variants: Vec::new(),
343            audience: Audience::Human,
344            depth: Depth::Summary,
345            locale: DEFAULT_LOCALE.to_string(),
346            headers: false,
347        }
348    }
349
350    /// The MCP `execute` tool description: the operating contract only, terse.
351    pub fn tool_description() -> Selector {
352        Selector {
353            concepts: vec![Concept::Foundations],
354            variants: vec![Variant::Rule, Variant::Contrast],
355            audience: Audience::Agent,
356            depth: Depth::Summary,
357            locale: DEFAULT_LOCALE.to_string(),
358            headers: false,
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn no_content() -> SchemaContent<'static> {
368        SchemaContent::new(&[])
369    }
370
371    #[test]
372    fn agent_onboarding_has_foundations_content() {
373        let out = compose(&Recipe::agent_onboarding(), &no_content());
374        assert!(out.contains("How kaish works"));
375        // A core guarantee must be present.
376        assert!(
377            out.to_lowercase().contains("word"),
378            "expected the no-word-splitting guarantee, got:\n{out}"
379        );
380    }
381
382    #[test]
383    fn audience_filters_human_only_from_agent() {
384        let agent = compose(&Recipe::agent_onboarding(), &no_content());
385        let human = compose(&Recipe::repl_welcome(), &no_content());
386        // The REPL welcome line is Human-only and must not leak into the agent blob.
387        assert!(human.contains("exit"), "human welcome should mention exit");
388        assert!(
389            !agent.contains("exit to quit") && !agent.contains("`exit`"),
390            "agent onboarding must not include the human welcome line"
391        );
392    }
393
394    #[test]
395    fn agent_only_fragment_excluded_from_human() {
396        let agent = compose(&Recipe::agent_onboarding(), &no_content());
397        let human = compose(&Recipe::repl_welcome(), &no_content());
398        // The "prefer --json when orchestrating" line is Agent-only.
399        assert!(agent.contains("orchestrat"), "agent blob should carry the agent-only json guidance");
400        assert!(!human.contains("orchestrat"), "agent-only guidance must not appear in human welcome");
401    }
402
403    #[test]
404    fn builtins_concept_pulls_from_generated_content() {
405        let schemas = vec![
406            ToolSchema::new("echo", "Print arguments"),
407            ToolSchema::new("cat", "Read a file"),
408        ];
409        let out = compose(&Recipe::agent_onboarding(), &SchemaContent::new(&schemas));
410        assert!(out.contains("## Builtins"));
411        assert!(out.contains("echo"));
412        assert!(out.contains("cat"));
413    }
414
415    #[test]
416    fn depth_summary_excludes_reference_only_fragments() {
417        let mut sel = Recipe::agent_onboarding();
418        sel.depth = Depth::Summary;
419        let summary = compose(&sel, &no_content());
420        sel.depth = Depth::Reference;
421        let reference = compose(&sel, &no_content());
422        // Reference is a superset: at least as long, and contains an example block.
423        assert!(reference.len() >= summary.len());
424        assert!(reference.contains("```"), "reference depth should include example fragments");
425    }
426
427    #[test]
428    fn fragments_render_in_registry_order_not_alphabetical() {
429        let out = compose(&Recipe::agent_onboarding(), &no_content());
430        let nws = out.find("No word splitting").expect("has no-word-splitting");
431        let fail = out.find("Fail loud").expect("has crash-not-corrupt");
432        assert!(
433            nws < fail,
434            "registry order should lead with no-word-splitting, not alphabetical:\n{out}"
435        );
436    }
437
438    #[test]
439    fn repl_welcome_intro_precedes_help_line() {
440        let out = compose(&Recipe::repl_welcome(), &no_content());
441        let intro = out.find("Bourne-like").expect("has intro");
442        let help_line = out.find("Type `help`").expect("has welcome line");
443        assert!(intro < help_line, "intro should precede the help/exit line:\n{out}");
444    }
445
446    #[test]
447    fn repl_welcome_is_headerless_and_terse() {
448        let out = compose(&Recipe::repl_welcome(), &no_content());
449        assert!(!out.contains("##"), "REPL banner must not carry markdown headers:\n{out}");
450        assert!(out.contains("help"), "welcome should point at help");
451        assert!(out.contains("exit"), "welcome should mention exit");
452    }
453
454    #[test]
455    fn agent_onboarding_renders_section_headers() {
456        let out = compose(&Recipe::agent_onboarding(), &no_content());
457        assert!(out.contains("## "), "markdown clients want section headers:\n{out}");
458    }
459
460    #[test]
461    fn syntax_md_matches_fragments() {
462        assert_eq!(
463            crate::content::SYNTAX,
464            render_syntax_reference(),
465            "content/en/syntax.md is stale — run \
466             `cargo run -p kaish-help --example regen_syntax`"
467        );
468    }
469
470    #[test]
471    fn syntax_reference_covers_core_topics() {
472        let out = render_syntax_reference();
473        for needle in ["## Variables", "## Quoting", "## Command Substitution", "## Functions"] {
474            assert!(out.contains(needle), "syntax reference missing {needle}");
475        }
476    }
477
478    #[test]
479    fn language_md_still_covers_the_syntax_surface() {
480        // LANGUAGE.md stays hand-authored (deeper human reference); guard that it
481        // hasn't lost coverage of the syntax topics the fragments single-source.
482        let lang = std::fs::read_to_string(concat!(
483            env!("CARGO_MANIFEST_DIR"),
484            "/../../docs/LANGUAGE.md"
485        ))
486        .expect("read docs/LANGUAGE.md");
487        for needle in [
488            "Quoting",
489            "Parameter Expansion",
490            "Pipes & Redirects",
491            "Command Substitution",
492            "Arithmetic",
493            "Functions",
494            "Control Flow",
495            "Test Expressions",
496        ] {
497            assert!(lang.contains(needle), "LANGUAGE.md no longer covers: {needle}");
498        }
499    }
500
501    #[test]
502    fn coverage_english_is_complete() {
503        assert!(
504            coverage(DEFAULT_LOCALE).is_empty(),
505            "English is canonical-complete by definition"
506        );
507    }
508
509    #[test]
510    fn coverage_reports_untranslated_locale() {
511        // No Japanese fragments exist yet, so every English slot is missing.
512        let missing = coverage("ja");
513        assert!(!missing.is_empty(), "ja has no fragments, so all slots are missing");
514        let english_count = FRAGMENTS.iter().filter(|f| f.locale == DEFAULT_LOCALE).count();
515        assert_eq!(missing.len(), english_count);
516    }
517}