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 const UNRANKED: u8 = u8::MAX;
103
104pub struct Fragment {
106 pub concept: Concept,
108 pub key: &'static str,
110 pub variant: Variant,
112 pub depth: Depth,
114 pub locale: &'static str,
116 pub audience: Option<Audience>,
118 pub rank: u8,
125 pub title: Option<&'static str>,
129 pub body: &'static str,
131}
132
133impl Fragment {
134 pub const fn ranked(self, rank: u8) -> Fragment {
138 Fragment { rank, ..self }
139 }
140}
141
142pub struct Selector {
144 pub concepts: Vec<Concept>,
146 pub variants: Vec<Variant>,
148 pub audience: Audience,
150 pub depth: Depth,
152 pub locale: String,
154 pub headers: bool,
157}
158
159pub trait GeneratedContent {
164 fn builtin_index(&self) -> Vec<(String, String)>;
166 fn tool_help(&self, name: &str) -> Option<String>;
168}
169
170pub struct SchemaContent<'a> {
172 schemas: &'a [ToolSchema],
173}
174
175impl<'a> SchemaContent<'a> {
176 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
195fn 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
204fn select_for_concept<'f>(concept: Concept, selector: &Selector) -> Vec<&'f Fragment> {
208 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 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 result.sort_by_key(|f| f.rank);
242 result
243}
244
245pub 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
290pub 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
308pub 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#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct MissingFragment {
328 pub concept: Concept,
330 pub key: &'static str,
332 pub variant: Variant,
334}
335
336pub 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
361pub struct Recipe;
366
367impl Recipe {
368 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 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 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 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 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 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 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 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 #[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 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 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}