use std::collections::HashMap;
use kaish_types::ToolSchema;
use crate::fragments::FRAGMENTS;
use crate::topic::tool_help;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Concept {
Model,
Syntax,
Foundations,
Builtins,
Limits,
}
impl Concept {
pub fn title(&self) -> &'static str {
match self {
Self::Model => "About kaish",
Self::Syntax => "Syntax",
Self::Foundations => "How kaish works",
Self::Builtins => "Builtins",
Self::Limits => "Limitations",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Variant {
Rule,
Example,
Contrast,
Rationale,
}
impl Variant {
fn order(&self) -> u8 {
match self {
Self::Rule => 0,
Self::Example => 1,
Self::Contrast => 2,
Self::Rationale => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Audience {
Agent,
Human,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Depth {
Summary,
Reference,
}
pub const DEFAULT_LOCALE: &str = "en";
pub struct Fragment {
pub concept: Concept,
pub key: &'static str,
pub variant: Variant,
pub depth: Depth,
pub locale: &'static str,
pub audience: Option<Audience>,
pub title: Option<&'static str>,
pub body: &'static str,
}
pub struct Selector {
pub concepts: Vec<Concept>,
pub variants: Vec<Variant>,
pub audience: Audience,
pub depth: Depth,
pub locale: String,
pub headers: bool,
}
pub trait GeneratedContent {
fn builtin_index(&self) -> Vec<(String, String)>;
fn tool_help(&self, name: &str) -> Option<String>;
}
pub struct SchemaContent<'a> {
schemas: &'a [ToolSchema],
}
impl<'a> SchemaContent<'a> {
pub fn new(schemas: &'a [ToolSchema]) -> Self {
Self { schemas }
}
}
impl GeneratedContent for SchemaContent<'_> {
fn builtin_index(&self) -> Vec<(String, String)> {
self.schemas
.iter()
.map(|s| (s.name.clone(), s.description.clone()))
.collect()
}
fn tool_help(&self, name: &str) -> Option<String> {
tool_help(name, self.schemas)
}
}
fn applicable(fragment: &Fragment, selector: &Selector) -> bool {
let variant_ok = selector.variants.is_empty() || selector.variants.contains(&fragment.variant);
let depth_ok = fragment.depth == Depth::Summary || selector.depth == Depth::Reference;
let audience_ok = fragment.audience.is_none_or(|a| a == selector.audience);
variant_ok && depth_ok && audience_ok
}
fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
let mut order: Vec<(&str, u8)> = Vec::new();
let mut chosen: HashMap<(&str, u8), &Fragment> = HashMap::new();
for fragment in FRAGMENTS
.iter()
.filter(|f| f.concept == concept && applicable(f, selector))
{
let slot = (fragment.key, fragment.variant.order());
match chosen.get(&slot) {
None => {
order.push(slot);
chosen.insert(slot, fragment);
}
Some(existing) => {
if fragment.locale == selector.locale && existing.locale != selector.locale {
chosen.insert(slot, fragment);
}
}
}
}
order
.iter()
.filter_map(|slot| chosen.get(slot).copied())
.collect()
}
pub fn compose(selector: &Selector, generated: &dyn GeneratedContent) -> String {
let mut sections: Vec<String> = Vec::new();
for &concept in &selector.concepts {
let mut body = String::new();
if concept == Concept::Builtins {
let index = generated.builtin_index();
if index.is_empty() {
continue;
}
let width = index.iter().map(|(name, _)| name.len()).max().unwrap_or(0);
for (name, desc) in index {
body.push_str(&format!(" {name:width$} {desc}\n"));
}
} else {
let fragments = select_for_concept(concept, selector);
if fragments.is_empty() {
continue;
}
for (i, fragment) in fragments.iter().enumerate() {
if i > 0 {
body.push('\n');
}
body.push_str(fragment.body.trim_end());
body.push('\n');
}
}
let body = body.trim_end();
if selector.headers {
sections.push(format!("## {}\n\n{}", concept.title(), body));
} else {
sections.push(body.to_string());
}
}
sections.join("\n\n")
}
pub fn render_syntax_reference() -> String {
let mut out = String::from("# kaish Syntax Reference\n");
for fragment in FRAGMENTS
.iter()
.filter(|f| f.concept == Concept::Syntax && f.locale == DEFAULT_LOCALE)
{
let title = fragment.title.unwrap_or(fragment.key);
out.push_str(&format!("\n## {title}\n\n{}\n", fragment.body.trim()));
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MissingFragment {
pub concept: Concept,
pub key: &'static str,
pub variant: Variant,
}
pub fn coverage(locale: &str) -> Vec<MissingFragment> {
FRAGMENTS
.iter()
.filter(|f| f.locale == DEFAULT_LOCALE)
.filter(|f| {
!FRAGMENTS.iter().any(|g| {
g.locale == locale
&& g.concept == f.concept
&& g.key == f.key
&& g.variant == f.variant
})
})
.map(|f| MissingFragment {
concept: f.concept,
key: f.key,
variant: f.variant,
})
.collect()
}
pub struct Recipe;
impl Recipe {
pub fn agent_onboarding() -> Selector {
Selector {
concepts: vec![Concept::Model, Concept::Foundations, Concept::Builtins],
variants: Vec::new(),
audience: Audience::Agent,
depth: Depth::Summary,
locale: DEFAULT_LOCALE.to_string(),
headers: true,
}
}
pub fn repl_welcome() -> Selector {
Selector {
concepts: vec![Concept::Model],
variants: Vec::new(),
audience: Audience::Human,
depth: Depth::Summary,
locale: DEFAULT_LOCALE.to_string(),
headers: false,
}
}
pub fn tool_description() -> Selector {
Selector {
concepts: vec![Concept::Foundations],
variants: vec![Variant::Rule, Variant::Contrast],
audience: Audience::Agent,
depth: Depth::Summary,
locale: DEFAULT_LOCALE.to_string(),
headers: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn no_content() -> SchemaContent<'static> {
SchemaContent::new(&[])
}
#[test]
fn agent_onboarding_has_foundations_content() {
let out = compose(&Recipe::agent_onboarding(), &no_content());
assert!(out.contains("How kaish works"));
assert!(
out.to_lowercase().contains("word"),
"expected the no-word-splitting guarantee, got:\n{out}"
);
}
#[test]
fn audience_filters_human_only_from_agent() {
let agent = compose(&Recipe::agent_onboarding(), &no_content());
let human = compose(&Recipe::repl_welcome(), &no_content());
assert!(human.contains("exit"), "human welcome should mention exit");
assert!(
!agent.contains("exit to quit") && !agent.contains("`exit`"),
"agent onboarding must not include the human welcome line"
);
}
#[test]
fn agent_only_fragment_excluded_from_human() {
let agent = compose(&Recipe::agent_onboarding(), &no_content());
let human = compose(&Recipe::repl_welcome(), &no_content());
assert!(agent.contains("orchestrat"), "agent blob should carry the agent-only json guidance");
assert!(!human.contains("orchestrat"), "agent-only guidance must not appear in human welcome");
}
#[test]
fn builtins_concept_pulls_from_generated_content() {
let schemas = vec![
ToolSchema::new("echo", "Print arguments"),
ToolSchema::new("cat", "Read a file"),
];
let out = compose(&Recipe::agent_onboarding(), &SchemaContent::new(&schemas));
assert!(out.contains("## Builtins"));
assert!(out.contains("echo"));
assert!(out.contains("cat"));
}
#[test]
fn depth_summary_excludes_reference_only_fragments() {
let mut sel = Recipe::agent_onboarding();
sel.depth = Depth::Summary;
let summary = compose(&sel, &no_content());
sel.depth = Depth::Reference;
let reference = compose(&sel, &no_content());
assert!(reference.len() >= summary.len());
assert!(reference.contains("```"), "reference depth should include example fragments");
}
#[test]
fn fragments_render_in_registry_order_not_alphabetical() {
let out = compose(&Recipe::agent_onboarding(), &no_content());
let nws = out.find("No word splitting").expect("has no-word-splitting");
let fail = out.find("Fail loud").expect("has crash-not-corrupt");
assert!(
nws < fail,
"registry order should lead with no-word-splitting, not alphabetical:\n{out}"
);
}
#[test]
fn repl_welcome_intro_precedes_help_line() {
let out = compose(&Recipe::repl_welcome(), &no_content());
let intro = out.find("Bourne-like").expect("has intro");
let help_line = out.find("Type `help`").expect("has welcome line");
assert!(intro < help_line, "intro should precede the help/exit line:\n{out}");
}
#[test]
fn repl_welcome_is_headerless_and_terse() {
let out = compose(&Recipe::repl_welcome(), &no_content());
assert!(!out.contains("##"), "REPL banner must not carry markdown headers:\n{out}");
assert!(out.contains("help"), "welcome should point at help");
assert!(out.contains("exit"), "welcome should mention exit");
}
#[test]
fn agent_onboarding_renders_section_headers() {
let out = compose(&Recipe::agent_onboarding(), &no_content());
assert!(out.contains("## "), "markdown clients want section headers:\n{out}");
}
#[test]
fn syntax_md_matches_fragments() {
assert_eq!(
crate::content::SYNTAX,
render_syntax_reference(),
"content/en/syntax.md is stale — run \
`cargo run -p kaish-help --example regen_syntax`"
);
}
#[test]
fn syntax_reference_covers_core_topics() {
let out = render_syntax_reference();
for needle in ["## Variables", "## Quoting", "## Command Substitution", "## Functions"] {
assert!(out.contains(needle), "syntax reference missing {needle}");
}
}
#[test]
fn language_md_still_covers_the_syntax_surface() {
let lang = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/LANGUAGE.md"
))
.expect("read docs/LANGUAGE.md");
for needle in [
"Quoting",
"Parameter Expansion",
"Pipes & Redirects",
"Command Substitution",
"Arithmetic",
"Functions",
"Control Flow",
"Test Expressions",
] {
assert!(lang.contains(needle), "LANGUAGE.md no longer covers: {needle}");
}
}
#[test]
fn coverage_english_is_complete() {
assert!(
coverage(DEFAULT_LOCALE).is_empty(),
"English is canonical-complete by definition"
);
}
#[test]
fn coverage_reports_untranslated_locale() {
let missing = coverage("ja");
assert!(!missing.is_empty(), "ja has no fragments, so all slots are missing");
let english_count = FRAGMENTS.iter().filter(|f| f.locale == DEFAULT_LOCALE).count();
assert_eq!(missing.len(), english_count);
}
}