1use std::collections::HashMap;
12
13use kaish_types::ToolSchema;
14
15use crate::fragments::FRAGMENTS;
16use crate::topic::tool_help;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum Concept {
21 Model,
23 Syntax,
25 Foundations,
28 Builtins,
30 Limits,
32 }
34
35impl Concept {
36 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum Variant {
55 Rule,
57 Example,
59 Contrast,
61 Rationale,
63}
64
65impl Variant {
66 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
80pub enum Audience {
81 Agent,
83 Human,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
89pub enum Depth {
90 Summary,
92 Reference,
94}
95
96pub const DEFAULT_LOCALE: &str = "en";
98
99pub struct Fragment {
101 pub concept: Concept,
103 pub key: &'static str,
105 pub variant: Variant,
107 pub depth: Depth,
109 pub locale: &'static str,
111 pub audience: Option<Audience>,
113 pub title: Option<&'static str>,
117 pub body: &'static str,
119}
120
121pub struct Selector {
123 pub concepts: Vec<Concept>,
125 pub variants: Vec<Variant>,
127 pub audience: Audience,
129 pub depth: Depth,
131 pub locale: String,
133 pub headers: bool,
136}
137
138pub trait GeneratedContent {
143 fn builtin_index(&self) -> Vec<(String, String)>;
145 fn tool_help(&self, name: &str) -> Option<String>;
147}
148
149pub struct SchemaContent<'a> {
151 schemas: &'a [ToolSchema],
152}
153
154impl<'a> SchemaContent<'a> {
155 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
174fn 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
183fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
187 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 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
218pub 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
263pub 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#[derive(Debug, Clone, PartialEq, Eq)]
283pub struct MissingFragment {
284 pub concept: Concept,
286 pub key: &'static str,
288 pub variant: Variant,
290}
291
292pub 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
317pub struct Recipe;
322
323impl Recipe {
324 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 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 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 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 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 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 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 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 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}