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 Overlay,
41 }
43
44impl Concept {
45 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum Variant {
65 Rule,
67 Example,
69 Contrast,
71 Rationale,
73}
74
75impl Variant {
76 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum Audience {
91 Agent,
93 Human,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
99pub enum Depth {
100 Summary,
102 Reference,
104}
105
106pub const DEFAULT_LOCALE: &str = "en";
108
109pub const UNRANKED: u8 = u8::MAX;
113
114pub struct Fragment {
116 pub concept: Concept,
118 pub key: &'static str,
120 pub variant: Variant,
122 pub depth: Depth,
124 pub locale: &'static str,
126 pub audience: Option<Audience>,
128 pub rank: u8,
135 pub title: Option<&'static str>,
139 pub body: &'static str,
141}
142
143impl Fragment {
144 pub const fn ranked(self, rank: u8) -> Fragment {
148 Fragment { rank, ..self }
149 }
150}
151
152pub struct Selector {
154 pub concepts: Vec<Concept>,
156 pub variants: Vec<Variant>,
158 pub audience: Audience,
160 pub depth: Depth,
162 pub locale: String,
164 pub headers: bool,
167}
168
169impl Selector {
170 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 pub fn without_overlay(mut self) -> Self {
186 self.concepts.retain(|c| *c != Concept::Overlay);
187 self
188 }
189}
190
191pub trait GeneratedContent {
196 fn builtin_index(&self) -> Vec<(String, String)>;
198 fn tool_help(&self, name: &str) -> Option<String>;
200}
201
202pub struct SchemaContent<'a> {
204 schemas: &'a [ToolSchema],
205}
206
207impl<'a> SchemaContent<'a> {
208 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
227fn 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
236fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
240 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 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 result.sort_by_key(|f| f.rank);
274 result
275}
276
277pub 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
322pub 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
340pub 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#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct MissingFragment {
360 pub concept: Concept,
362 pub key: &'static str,
364 pub variant: Variant,
366}
367
368pub 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
393pub struct Recipe;
398
399impl Recipe {
400 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 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 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 assert!(
453 out.to_lowercase().contains("word"),
454 "expected the no-word-splitting guarantee, got:\n{out}"
455 );
456 }
457
458 #[test]
468 fn agent_onboarding_covers_the_verified_syntax_gaps() {
469 let out = compose(&Recipe::agent_onboarding(), &no_content());
470 for needle in [
471 "compound statement is a pipeline stage",
472 "is not a command",
473 "lowercase `true`/`false` are booleans",
474 ] {
475 assert!(out.contains(needle), "agent_onboarding missing {needle:?}:\n{out}");
476 }
477 }
478
479 #[test]
480 fn tool_description_covers_the_verified_syntax_gaps() {
481 let out = compose(&Recipe::tool_description(), &no_content());
482 for needle in [
483 "compound statement is a pipeline stage",
484 "is not a command",
485 "lowercase `true`/`false` are booleans",
486 ] {
487 assert!(out.contains(needle), "tool_description missing {needle:?}:\n{out}");
488 }
489 }
490
491 #[test]
496 fn overlay_excluded_by_default() {
497 let onboarding = compose(&Recipe::agent_onboarding(), &no_content());
498 let tool_desc = compose(&Recipe::tool_description(), &no_content());
499 for (name, out) in [("agent_onboarding", &onboarding), ("tool_description", &tool_desc)] {
500 assert!(
501 !out.contains("Overlay mode") && !out.contains("kaish-vfs commit"),
502 "{name} must not carry overlay guidance by default:\n{out}"
503 );
504 }
505 }
506
507 #[test]
510 fn overlay_can_be_composed_in_with_with_overlay() {
511 let out = compose(&Recipe::agent_onboarding().with_overlay(), &no_content());
512 assert!(out.contains("Overlay mode"), "with_overlay() must add the overlay guidance:\n{out}");
513 assert!(out.contains("kaish-vfs commit"), "overlay guidance should mention kaish-vfs commit:\n{out}");
514 assert!(out.contains("How kaish works"), "with_overlay() must not drop the rest of the spine:\n{out}");
515 }
516
517 #[test]
521 fn without_overlay_removes_it_and_is_a_noop_when_absent() {
522 let with_it = compose(&Recipe::agent_onboarding().with_overlay(), &no_content());
523 assert!(with_it.contains("Overlay mode"));
524
525 let round_tripped = compose(&Recipe::agent_onboarding().with_overlay().without_overlay(), &no_content());
526 assert!(
527 !round_tripped.contains("Overlay mode"),
528 "without_overlay() must remove it again:\n{round_tripped}"
529 );
530
531 let baseline = compose(&Recipe::agent_onboarding(), &no_content());
532 let noop = compose(&Recipe::agent_onboarding().without_overlay(), &no_content());
533 assert_eq!(baseline, noop, "without_overlay() must be a no-op when overlay was never selected");
534 }
535
536 #[test]
537 fn audience_filters_human_only_from_agent() {
538 let agent = compose(&Recipe::agent_onboarding(), &no_content());
539 let human = compose(&Recipe::repl_welcome(), &no_content());
540 assert!(human.contains("exit"), "human welcome should mention exit");
542 assert!(
543 !agent.contains("exit to quit") && !agent.contains("`exit`"),
544 "agent onboarding must not include the human welcome line"
545 );
546 }
547
548 #[test]
549 fn agent_only_fragment_excluded_from_human() {
550 let agent = compose(&Recipe::agent_onboarding(), &no_content());
551 let human = compose(&Recipe::repl_welcome(), &no_content());
552 assert!(agent.contains("orchestrat"), "agent blob should carry the agent-only json guidance");
554 assert!(!human.contains("orchestrat"), "agent-only guidance must not appear in human welcome");
555 }
556
557 #[test]
558 fn builtins_concept_pulls_from_generated_content() {
559 let schemas = vec![
560 ToolSchema::new("echo", "Print arguments"),
561 ToolSchema::new("cat", "Read a file"),
562 ];
563 let out = compose(&Recipe::agent_onboarding(), &SchemaContent::new(&schemas));
564 assert!(out.contains("## Builtins"));
565 assert!(out.contains("echo"));
566 assert!(out.contains("cat"));
567 }
568
569 #[test]
570 fn depth_summary_excludes_reference_only_fragments() {
571 let mut sel = Recipe::agent_onboarding();
572 sel.depth = Depth::Summary;
573 let summary = compose(&sel, &no_content());
574 sel.depth = Depth::Reference;
575 let reference = compose(&sel, &no_content());
576 assert!(reference.len() >= summary.len());
578 assert!(reference.contains("```"), "reference depth should include example fragments");
579 }
580
581 #[test]
582 fn onboarding_renders_in_importance_rank_order() {
583 let out = compose(&Recipe::agent_onboarding(), &no_content());
584 let nws = out.find("No word splitting").expect("has no-word-splitting");
585 let fail = out.find("Fail loud").expect("has crash-not-corrupt");
586 assert!(
587 nws < fail,
588 "the most-important rule (no-word-splitting, rank 0) must lead:\n{out}"
589 );
590 let subst = out.find("carries structured data").expect("has structured-substitution");
594 let output = out.find("Structured output").expect("has structured-output");
595 assert!(
596 subst < output,
597 "rank must reorder ahead of registry position:\n{out}"
598 );
599 }
600
601 #[test]
608 fn onboarding_spine_stays_within_budget() {
609 const BUDGET: usize = 3500;
610 let out = compose(&Recipe::agent_onboarding(), &no_content());
611 assert!(
612 out.len() <= BUDGET,
613 "always-on onboarding spine is {} chars (budget {BUDGET}) — trim or defer \
614 low-rank content to a help topic:\n{out}",
615 out.len()
616 );
617 }
618
619 #[test]
620 fn repl_welcome_intro_precedes_help_line() {
621 let out = compose(&Recipe::repl_welcome(), &no_content());
622 let intro = out.find("Bourne-like").expect("has intro");
623 let help_line = out.find("Type `help`").expect("has welcome line");
624 assert!(intro < help_line, "intro should precede the help/exit line:\n{out}");
625 }
626
627 #[test]
628 fn repl_welcome_is_headerless_and_terse() {
629 let out = compose(&Recipe::repl_welcome(), &no_content());
630 assert!(!out.contains("##"), "REPL banner must not carry markdown headers:\n{out}");
631 assert!(out.contains("help"), "welcome should point at help");
632 assert!(out.contains("exit"), "welcome should mention exit");
633 }
634
635 #[test]
636 fn agent_onboarding_renders_section_headers() {
637 let out = compose(&Recipe::agent_onboarding(), &no_content());
638 assert!(out.contains("## "), "markdown clients want section headers:\n{out}");
639 }
640
641 #[test]
642 fn syntax_md_matches_fragments() {
643 assert_eq!(
644 crate::content::SYNTAX,
645 render_syntax_reference(),
646 "content/en/syntax.md is stale — run \
647 `cargo run -p kaish-help --example regen_syntax`"
648 );
649 }
650
651 #[test]
652 fn syntax_reference_covers_core_topics() {
653 let out = render_syntax_reference();
654 for needle in ["## Variables", "## Quoting", "## Command Substitution", "## Functions"] {
655 assert!(out.contains(needle), "syntax reference missing {needle}");
656 }
657 }
658
659 #[test]
660 fn language_md_still_covers_the_syntax_surface() {
661 let lang = std::fs::read_to_string(concat!(
664 env!("CARGO_MANIFEST_DIR"),
665 "/../../docs/LANGUAGE.md"
666 ))
667 .expect("read docs/LANGUAGE.md");
668 for needle in [
669 "Quoting",
670 "Parameter Expansion",
671 "Pipes & Redirects",
672 "Command Substitution",
673 "Arithmetic",
674 "Functions",
675 "Control Flow",
676 "Test Expressions",
677 ] {
678 assert!(lang.contains(needle), "LANGUAGE.md no longer covers: {needle}");
679 }
680 }
681
682 #[test]
683 fn coverage_english_is_complete() {
684 assert!(
685 coverage(DEFAULT_LOCALE).is_empty(),
686 "English is canonical-complete by definition"
687 );
688 }
689
690 #[test]
691 fn coverage_reports_untranslated_locale() {
692 let missing = coverage("ja");
694 assert!(!missing.is_empty(), "ja has no fragments, so all slots are missing");
695 let english_count = FRAGMENTS.iter().filter(|f| f.locale == DEFAULT_LOCALE).count();
696 assert_eq!(missing.len(), english_count);
697 }
698}