Skip to main content

synaps_cli/
help.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4const BUILTIN_HELP_JSON: &str = include_str!("../assets/help.json");
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
7pub enum HelpTopicKind {
8    Branch,
9    #[default]
10    Command,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct HelpExample {
15    pub command: String,
16    pub description: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct HelpEntry {
21    pub id: String,
22    pub command: String,
23    pub title: String,
24    pub summary: String,
25    #[serde(default = "default_plugin_category")]
26    pub category: String,
27    #[serde(default)]
28    pub topic: HelpTopicKind,
29    #[serde(default)]
30    pub protected: bool,
31    #[serde(default)]
32    pub common: bool,
33    #[serde(default)]
34    pub aliases: Vec<String>,
35    #[serde(default)]
36    pub keywords: Vec<String>,
37    #[serde(default)]
38    pub lines: Vec<String>,
39    #[serde(default)]
40    pub usage: Option<String>,
41    #[serde(default)]
42    pub examples: Vec<HelpExample>,
43    #[serde(default)]
44    pub related: Vec<String>,
45    #[serde(default)]
46    pub source: Option<String>,
47}
48
49#[derive(Debug, Clone)]
50pub struct HelpRegistry {
51    entries: Vec<HelpEntry>,
52}
53
54#[derive(Debug, Clone)]
55pub struct HelpFindState {
56    entries: Vec<HelpEntry>,
57    filter: String,
58    cursor: usize,
59    scroll: usize,
60    visible_height: usize,
61    detail_idx: Option<usize>,
62    recently_opened: Vec<String>,
63    help_commands_only: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum HelpFindRow<'a> {
68    Category(&'a str),
69    Entry(&'a HelpEntry),
70}
71
72impl<'a> HelpFindRow<'a> {
73    pub fn category(&self) -> Option<&'a str> {
74        match self {
75            Self::Category(category) => Some(category),
76            Self::Entry(_) => None,
77        }
78    }
79
80    pub fn entry(&self) -> Option<&'a HelpEntry> {
81        match self {
82            Self::Category(_) => None,
83            Self::Entry(entry) => Some(entry),
84        }
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct HighlightSegment {
90    pub text: String,
91    pub matched: bool,
92}
93
94impl HelpFindState {
95    pub fn new(entries: Vec<HelpEntry>, query: &str) -> Self {
96        Self::new_with_scope(entries, query, false)
97    }
98
99    pub fn new_help_commands(entries: Vec<HelpEntry>, query: &str) -> Self {
100        Self::new_with_scope(entries, query, true)
101    }
102
103    fn new_with_scope(entries: Vec<HelpEntry>, query: &str, help_commands_only: bool) -> Self {
104        let mut state = Self {
105            entries,
106            filter: query.trim().to_string(),
107            cursor: 0,
108            scroll: 0,
109            visible_height: 10,
110            detail_idx: None,
111            recently_opened: Vec::new(),
112            help_commands_only,
113        };
114        state.reset_position();
115        state
116    }
117
118    pub fn filter(&self) -> &str {
119        &self.filter
120    }
121
122    pub fn cursor(&self) -> usize {
123        self.cursor
124    }
125
126    pub fn result_cursor(&self) -> usize {
127        self.filtered_rows()
128            .iter()
129            .take(self.cursor + 1)
130            .filter(|row| row.entry().is_some())
131            .count()
132            .saturating_sub(1)
133    }
134
135    pub fn scroll(&self) -> usize {
136        self.scroll
137    }
138
139    pub fn set_visible_height(&mut self, height: usize) {
140        self.visible_height = height.max(1);
141        self.scroll_to_cursor();
142    }
143
144    pub fn filtered_entries(&self) -> Vec<&HelpEntry> {
145        ranked_entries_with_mru(&self.entries, &self.filter, &self.recently_opened)
146            .into_iter()
147            .filter(|entry| !self.help_commands_only || is_help_command(entry))
148            .collect()
149    }
150
151    pub fn filtered_rows(&self) -> Vec<HelpFindRow<'_>> {
152        let entries = self.filtered_entries();
153        if !self.filter.trim().is_empty() {
154            return entries.into_iter().map(HelpFindRow::Entry).collect();
155        }
156
157        let mut category_names: Vec<&str> = entries.iter().map(|entry| display_category(entry)).collect();
158        category_names.sort_by(|a, b| {
159            category_sort_key(a)
160                .cmp(&category_sort_key(b))
161                .then_with(|| {
162                    category_best_score(&entries, b)
163                        .cmp(&category_best_score(&entries, a))
164                })
165                .then_with(|| a.cmp(b))
166        });
167        category_names.dedup();
168
169        let mut rows = Vec::new();
170        for category in category_names {
171            rows.push(HelpFindRow::Category(category));
172            let mut category_entries = entries
173                .iter()
174                .copied()
175                .filter(|entry| display_category(entry) == category)
176                .collect::<Vec<_>>();
177            category_entries.sort_by(|a, b| {
178                help_parent_sort_key(a)
179                    .cmp(&help_parent_sort_key(b))
180                    .then_with(|| a.command.cmp(&b.command))
181            });
182            rows.extend(category_entries.into_iter().map(HelpFindRow::Entry));
183        }
184        rows
185    }
186
187    pub fn no_results_message(&self) -> String {
188        let query = self.filter.trim();
189        if query.is_empty() {
190            "No help topics available. Try: model, settings, plugins, sessions, doctor".to_string()
191        } else {
192            format!(
193                "No help matches for '{}'. Try: model, settings, plugins, sessions, doctor",
194                query
195            )
196        }
197    }
198
199    pub fn selected(&self) -> Option<&HelpEntry> {
200        self.filtered_rows().get(self.cursor).and_then(HelpFindRow::entry)
201    }
202
203    pub fn open_selected(&mut self) {
204        let selected_command = self.selected().map(|entry| entry.command.clone());
205        if let Some(command) = selected_command.as_ref() {
206            self.remember_opened(command);
207        }
208        self.detail_idx = selected_command
209            .and_then(|command| self.entries.iter().position(|entry| entry.command == command));
210    }
211
212    pub fn close_detail(&mut self) {
213        self.detail_idx = None;
214    }
215
216    pub fn detail_entry(&self) -> Option<&HelpEntry> {
217        self.detail_idx.and_then(|idx| self.entries.get(idx))
218    }
219
220    pub fn move_down(&mut self) {
221        let rows = self.filtered_rows();
222        if rows.is_empty() {
223            self.cursor = 0;
224            self.scroll = 0;
225            return;
226        }
227        let mut next = (self.cursor + 1).min(rows.len() - 1);
228        while next < rows.len() && rows[next].entry().is_none() {
229            if next == rows.len() - 1 {
230                break;
231            }
232            next += 1;
233        }
234        if rows[next].entry().is_some() {
235            self.cursor = next;
236        }
237        self.scroll_to_cursor();
238    }
239
240    pub fn move_up(&mut self) {
241        let rows = self.filtered_rows();
242        if rows.is_empty() {
243            self.cursor = 0;
244            self.scroll = 0;
245            return;
246        }
247        let mut next = self.cursor.saturating_sub(1);
248        while next > 0 && rows[next].entry().is_none() {
249            next = next.saturating_sub(1);
250        }
251        if rows[next].entry().is_some() {
252            self.cursor = next;
253        }
254        self.scroll_to_cursor();
255    }
256
257    pub fn push_char(&mut self, ch: char) {
258        self.filter.push(ch);
259        self.reset_position();
260    }
261
262    pub fn backspace(&mut self) {
263        self.filter.pop();
264        self.reset_position();
265    }
266
267    pub fn clear_filter(&mut self) {
268        self.filter.clear();
269        self.reset_position();
270    }
271
272    fn reset_position(&mut self) {
273        self.cursor = self.first_entry_row_index();
274        self.scroll = 0;
275    }
276
277    fn first_entry_row_index(&self) -> usize {
278        self.filtered_rows()
279            .iter()
280            .position(|row| row.entry().is_some())
281            .unwrap_or(0)
282    }
283
284    fn remember_opened(&mut self, command: &str) {
285        self.recently_opened.retain(|existing| existing != command);
286        self.recently_opened.insert(0, command.to_string());
287        self.recently_opened.truncate(10);
288    }
289
290    fn scroll_to_cursor(&mut self) {
291        if self.cursor < self.scroll {
292            self.scroll = self.cursor;
293        }
294        let bottom = self.scroll + self.visible_height;
295        if self.cursor >= bottom {
296            self.scroll = self.cursor + 1 - self.visible_height;
297        }
298        let len = self.filtered_rows().len();
299        if self.cursor >= len {
300            self.cursor = self.first_entry_row_index();
301            self.scroll = 0;
302        }
303    }
304}
305
306impl HelpRegistry {
307    pub fn new(core_entries: Vec<HelpEntry>, plugin_entries: Vec<HelpEntry>) -> Self {
308        let protected = protected_commands(&core_entries);
309        let mut seen = HashSet::new();
310        let mut entries = Vec::new();
311
312        for mut entry in core_entries {
313            normalize_command(&mut entry);
314            if seen.insert(entry.command.clone()) {
315                entries.push(entry);
316            }
317        }
318
319        for mut entry in plugin_entries {
320            normalize_command(&mut entry);
321            if protected.contains(&entry.command)
322                || protected.contains(&entry.id)
323                || entry.aliases.iter().any(|alias| protected.contains(alias))
324            {
325                tracing::warn!(
326                    command = %entry.command,
327                    id = %entry.id,
328                    "plugin help entry conflicts with protected namespace; ignoring"
329                );
330                continue;
331            }
332            if entry.protected {
333                entry.protected = false;
334            }
335            entry.source = Some(plugin_source_label(entry.source.as_deref()));
336            if entry.category.trim().is_empty() || entry.category.eq_ignore_ascii_case("plugin") {
337                entry.category = extension_help_category(entry.source.as_deref());
338            }
339            if seen.insert(entry.command.clone()) {
340                entries.push(entry);
341            }
342        }
343
344        entries.sort_by(|a, b| a.command.cmp(&b.command));
345        Self { entries }
346    }
347
348    pub fn entries(&self) -> &[HelpEntry] {
349        &self.entries
350    }
351
352    pub fn entry_by_command(&self, command: &str) -> Option<&HelpEntry> {
353        let needle = normalize_query_command(command);
354        self.entries.iter().find(|entry| {
355            entry.command == needle || entry.aliases.iter().any(|alias| normalize_query_command(alias) == needle)
356        })
357    }
358
359    pub fn branch(&self, topic: &str) -> Option<&HelpEntry> {
360        let normalized = topic.trim().trim_start_matches("/help").trim().trim_start_matches('/');
361        self.entries.iter().find(|entry| {
362            entry.topic == HelpTopicKind::Branch
363                && (entry.id == normalized
364                    || entry.command == format!("/help {}", normalized)
365                    || entry.aliases.iter().any(|alias| alias.trim_start_matches("/help ") == normalized))
366        })
367    }
368
369    pub fn search(&self, query: &str) -> Vec<&HelpEntry> {
370        ranked_entries(&self.entries, query)
371    }
372
373    pub fn entry_for_help_topic(&self, topic: &str) -> Option<&HelpEntry> {
374        let normalized = normalize_help_topic(topic);
375        if normalized.is_empty() {
376            return None;
377        }
378
379        self.entry_by_command_exact(&format!("/help {}", normalized))
380            .or_else(|| self.entry_by_command_exact(&normalized))
381            .or_else(|| self.entry_by_command_exact(&format!("/{}", normalized)))
382            .or_else(|| self.entry_by_command(&format!("/{}", normalized)))
383            .or_else(|| self.branch(&normalized))
384    }
385
386    pub fn command_prefix_match_count(&self, partial: &str) -> usize {
387        let needle = partial.trim().trim_start_matches('/').to_ascii_lowercase();
388        if needle.is_empty() {
389            return 0;
390        }
391        self.entries
392            .iter()
393            .filter(|entry| {
394                entry.command.trim_start_matches('/').to_ascii_lowercase().starts_with(&needle)
395                    || entry.aliases.iter().any(|alias| {
396                        alias.trim_start_matches('/').to_ascii_lowercase().starts_with(&needle)
397                    })
398            })
399            .count()
400    }
401
402    fn entry_by_command_exact(&self, command: &str) -> Option<&HelpEntry> {
403        let needle = normalize_query_command(command);
404        self.entries.iter().find(|entry| entry.command == needle)
405    }
406}
407
408pub fn builtin_entries() -> Vec<HelpEntry> {
409    serde_json::from_str(BUILTIN_HELP_JSON).expect("assets/help.json must be valid help JSON")
410}
411
412fn default_plugin_category() -> String {
413    "Plugin".to_string()
414}
415
416fn category_sort_key(category: &str) -> u8 {
417    if category == "Help commands" {
418        1
419    } else if is_extension_help_category(category) {
420        2
421    } else {
422        0
423    }
424}
425
426fn help_parent_sort_key(entry: &HelpEntry) -> usize {
427    if is_help_command(entry) {
428        entry.command.matches(' ').count()
429    } else {
430        0
431    }
432}
433
434fn display_category(entry: &HelpEntry) -> &str {
435    if is_help_command(entry) {
436        "Help commands"
437    } else {
438        entry.category.as_str()
439    }
440}
441
442fn category_best_score(entries: &[&HelpEntry], category: &str) -> i32 {
443    entries
444        .iter()
445        .filter(|entry| display_category(entry) == category)
446        .map(|entry| empty_query_score(entry))
447        .max()
448        .unwrap_or(0)
449}
450
451fn is_help_command(entry: &HelpEntry) -> bool {
452    entry.command == "/help" || entry.command.starts_with("/help ")
453}
454
455pub fn prefilter_query_for_slash_command(input: &str) -> Option<String> {
456    let trimmed = input.trim();
457    let partial = trimmed.strip_prefix('/')?.trim();
458    if partial.is_empty() {
459        return None;
460    }
461    Some(partial.to_string())
462}
463
464pub fn render_help(registry: &HelpRegistry, branch: Option<&str>) -> Option<String> {
465    match branch.map(str::trim).filter(|s| !s.is_empty()) {
466        None => registry.entry_by_command("/help").map(render_entry),
467        Some("find") => registry.entry_by_command("/help find").map(render_entry),
468        Some("topics") => Some(render_topics(registry)),
469        Some("reference") => Some(render_reference(registry)),
470        Some(topic) => registry
471            .entry_for_help_topic(topic)
472            .map(render_entry)
473            .or_else(|| Some(render_unknown_topic(registry, topic))),
474    }
475}
476
477pub fn render_entry(entry: &HelpEntry) -> String {
478    let mut lines = vec![entry.title.clone(), String::new(), entry.summary.clone()];
479
480    if !entry.lines.is_empty() {
481        lines.push(String::new());
482        lines.extend(body_lines_without_structured_related(entry));
483    }
484
485    append_usage_examples_related(&mut lines, entry);
486    lines.join("\n")
487}
488
489fn body_lines_without_structured_related(entry: &HelpEntry) -> Vec<String> {
490    if entry.related.is_empty() {
491        return entry.lines.clone();
492    }
493    let mut lines = entry.lines.clone();
494    while lines.last().is_some_and(|line| line.trim().is_empty()) {
495        lines.pop();
496    }
497    if lines
498        .last()
499        .is_some_and(|line| line.trim_start().starts_with("Related:"))
500    {
501        lines.pop();
502        while lines.last().is_some_and(|line| line.trim().is_empty()) {
503            lines.pop();
504        }
505    }
506    lines
507}
508
509fn render_topics(registry: &HelpRegistry) -> String {
510    let mut entries: Vec<&HelpEntry> = registry
511        .entries()
512        .iter()
513        .filter(|entry| entry.topic == HelpTopicKind::Branch)
514        .collect();
515    entries.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.command.cmp(&b.command)));
516
517    let mut lines = vec![
518        "Help topics".to_string(),
519        String::new(),
520        "Conceptual guides and discovery paths. Use /help <topic> for details.".to_string(),
521        String::new(),
522    ];
523
524    for entry in entries {
525        lines.push(format!("  {} — {}", entry.command, entry.summary));
526    }
527
528    lines.join("\n")
529}
530
531fn render_reference(registry: &HelpRegistry) -> String {
532    let mut entries: Vec<&HelpEntry> = registry.entries().iter().collect();
533    entries.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.command.cmp(&b.command)));
534
535    let mut lines = vec![
536        "Help reference".to_string(),
537        String::new(),
538        "All help entries grouped by category.".to_string(),
539    ];
540    let mut current_category: Option<&str> = None;
541
542    for entry in entries {
543        if current_category != Some(entry.category.as_str()) {
544            current_category = Some(entry.category.as_str());
545            lines.push(String::new());
546            lines.push(entry.category.clone());
547        }
548        lines.push(format!("  {} — {}", entry.command, entry.summary));
549    }
550
551    lines.join("\n")
552}
553
554pub fn source_display(entry: &HelpEntry) -> Option<String> {
555    match entry.source.as_deref() {
556        Some(source) if !source.trim().is_empty() => Some(plugin_source_label(Some(source))),
557        _ => None,
558    }
559}
560
561fn append_usage_examples_related(lines: &mut Vec<String>, entry: &HelpEntry) {
562    if let Some(usage) = entry.usage.as_ref().filter(|usage| !usage.trim().is_empty()) {
563        lines.push(String::new());
564        lines.push("Usage".to_string());
565        lines.push(format!("  {}", usage));
566    }
567
568    if !entry.examples.is_empty() {
569        lines.push(String::new());
570        lines.push("Examples".to_string());
571        for example in &entry.examples {
572            if example.description.trim().is_empty() {
573                lines.push(format!("  {}", example.command));
574            } else {
575                lines.push(format!("  {:<16} {}", example.command, example.description));
576            }
577        }
578    }
579
580    if !entry.related.is_empty() {
581        lines.push(String::new());
582        lines.push(format!("Related: {}", entry.related.join(", ")));
583    }
584}
585
586fn normalize_command(entry: &mut HelpEntry) {
587    entry.command = normalize_query_command(&entry.command);
588    entry.aliases = entry.aliases.iter().map(|alias| normalize_query_command(alias)).collect();
589}
590
591fn normalize_query_command(command: &str) -> String {
592    let trimmed = command.trim();
593    if trimmed.starts_with('/') {
594        trimmed.to_string()
595    } else {
596        format!("/{}", trimmed)
597    }
598}
599
600fn normalize_help_topic(topic: &str) -> String {
601    let mut normalized = topic.trim();
602    if let Some(rest) = normalized.strip_prefix("/help") {
603        normalized = rest.trim();
604    }
605    normalized.trim_start_matches('/').trim().to_ascii_lowercase()
606}
607
608fn render_unknown_topic(registry: &HelpRegistry, topic: &str) -> String {
609    let mut lines = vec![
610        format!("No help topic for '{}'.", topic),
611        String::new(),
612        "Try /help find to search every topic.".to_string(),
613    ];
614    let suggestions = closest_help_matches(registry, topic, 3);
615    if !suggestions.is_empty() {
616        lines.push(String::new());
617        lines.push(format!("Closest matches: {}", suggestions.join(", ")));
618    }
619    lines.join("\n")
620}
621
622fn closest_help_matches(registry: &HelpRegistry, topic: &str, limit: usize) -> Vec<String> {
623    let needle = normalize_help_topic(topic);
624    if needle.is_empty() {
625        return Vec::new();
626    }
627
628    let mut scored: Vec<(&HelpEntry, usize)> = registry
629        .entries()
630        .iter()
631        .filter_map(|entry| {
632            suggestion_distance(entry, &needle)
633                .filter(|distance| *distance <= 3)
634                .map(|distance| (entry, distance))
635        })
636        .collect();
637
638    scored.sort_by(|(entry_a, distance_a), (entry_b, distance_b)| {
639        distance_a
640            .cmp(distance_b)
641            .then_with(|| entry_b.common.cmp(&entry_a.common))
642            .then_with(|| entry_a.command.len().cmp(&entry_b.command.len()))
643            .then_with(|| entry_a.command.cmp(&entry_b.command))
644    });
645    scored
646        .into_iter()
647        .map(|(entry, _)| entry.command.clone())
648        .take(limit)
649        .collect()
650}
651
652fn suggestion_distance(entry: &HelpEntry, needle: &str) -> Option<usize> {
653    entry
654        .aliases
655        .iter()
656        .map(|alias| normalize_help_topic(alias))
657        .chain(std::iter::once(normalize_help_topic(&entry.command)))
658        .chain(std::iter::once(entry.id.to_ascii_lowercase()))
659        .chain(std::iter::once(entry.title.to_ascii_lowercase()))
660        .map(|candidate| levenshtein(needle, candidate.trim_start_matches("help ")))
661        .min()
662}
663
664fn levenshtein(a: &str, b: &str) -> usize {
665    let b_chars: Vec<char> = b.chars().collect();
666    let mut costs: Vec<usize> = (0..=b_chars.len()).collect();
667
668    for (i, ca) in a.chars().enumerate() {
669        let mut previous = costs[0];
670        costs[0] = i + 1;
671        for (j, cb) in b_chars.iter().enumerate() {
672            let current = costs[j + 1];
673            costs[j + 1] = if ca == *cb {
674                previous
675            } else {
676                1 + previous.min(current).min(costs[j])
677            };
678            previous = current;
679        }
680    }
681
682    costs[b_chars.len()]
683}
684
685fn plugin_source_label(source: Option<&str>) -> String {
686    match source.map(str::trim).filter(|source| !source.is_empty()) {
687        None => "plugin".to_string(),
688        Some(source) if source.eq_ignore_ascii_case("plugin") => "plugin".to_string(),
689        Some(source) if source.to_ascii_lowercase().starts_with("plugin ") => source.to_string(),
690        Some(source) => format!("plugin {}", source),
691    }
692}
693
694fn extension_help_category(source: Option<&str>) -> String {
695    let Some(source) = source.map(str::trim).filter(|source| !source.is_empty()) else {
696        return "Extensions".to_string();
697    };
698    let plugin_name = source
699        .strip_prefix("plugin ")
700        .unwrap_or(source)
701        .trim();
702    if plugin_name.is_empty() || plugin_name.eq_ignore_ascii_case("plugin") {
703        return "Extensions".to_string();
704    }
705    plugin_name
706        .split(['-', '_', ' '])
707        .filter(|word| !word.is_empty())
708        .map(title_case_word)
709        .collect::<Vec<_>>()
710        .join(" ")
711}
712
713fn title_case_word(word: &str) -> String {
714    let mut chars = word.chars();
715    match chars.next() {
716        None => String::new(),
717        Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_ascii_lowercase(),
718    }
719}
720
721fn is_extension_help_category(category: &str) -> bool {
722    !category.is_empty()
723        && category != "Help commands"
724        && !matches!(
725            category,
726            "Advanced"
727                | "Core"
728                | "Diagnostics"
729                | "Extensions"
730                | "Models"
731                | "Plugins"
732                | "Sessions"
733                | "Settings"
734                | "Tools"
735        )
736}
737
738fn protected_commands(entries: &[HelpEntry]) -> HashSet<String> {
739    let mut protected = HashSet::new();
740    for entry in entries.iter().filter(|entry| entry.protected) {
741        protected.insert(normalize_query_command(&entry.command));
742        protected.insert(entry.id.clone());
743        for alias in &entry.aliases {
744            protected.insert(normalize_query_command(alias));
745        }
746    }
747    for builtin in crate::skills::BUILTIN_COMMANDS {
748        protected.insert(format!("/{}", builtin));
749        protected.insert((*builtin).to_string());
750    }
751    protected
752}
753
754fn ranked_entries<'a>(entries: &'a [HelpEntry], query: &str) -> Vec<&'a HelpEntry> {
755    ranked_entries_with_mru(entries, query, &[])
756}
757
758fn ranked_entries_with_mru<'a>(entries: &'a [HelpEntry], query: &str, recently_opened: &[String]) -> Vec<&'a HelpEntry> {
759    let needle = query.trim().to_ascii_lowercase();
760    let mut scored: Vec<(&HelpEntry, i32)> = entries
761        .iter()
762        .filter_map(|entry| {
763            if needle.is_empty() {
764                Some((entry, empty_query_score(entry)))
765            } else {
766                match_score(entry, &needle).map(|score| (entry, score + mru_bonus(entry, recently_opened)))
767            }
768        })
769        .collect();
770
771    scored.sort_by(|(a, score_a), (b, score_b)| {
772        score_b
773            .cmp(score_a)
774            .then_with(|| a.category.cmp(&b.category))
775            .then_with(|| a.command.cmp(&b.command))
776    });
777    scored.into_iter().map(|(entry, _)| entry).collect()
778}
779
780fn empty_query_score(entry: &HelpEntry) -> i32 {
781    let mut score = 0;
782    if entry.common {
783        score += 2_000;
784    }
785    if entry.category.eq_ignore_ascii_case("core") {
786        score += 1_000;
787    }
788    score
789}
790
791fn match_score(entry: &HelpEntry, needle: &str) -> Option<i32> {
792    let command = entry.command.to_ascii_lowercase();
793    let title = entry.title.to_ascii_lowercase();
794    if command == needle || command.trim_start_matches('/') == needle {
795        return Some(11_000 + common_bonus(entry));
796    }
797    if title == needle {
798        return Some(10_000 + common_bonus(entry));
799    }
800    if command.starts_with(needle)
801        || command.trim_start_matches('/').starts_with(needle.trim_start_matches('/'))
802    {
803        return Some(8_500 + common_bonus(entry));
804    }
805    if title.starts_with(needle) {
806        return Some(8_000 + common_bonus(entry));
807    }
808    if entry
809        .aliases
810        .iter()
811        .any(|alias| field_matches(alias, needle))
812    {
813        return Some(6_000 + common_bonus(entry));
814    }
815    if entry
816        .keywords
817        .iter()
818        .any(|keyword| field_matches(keyword, needle))
819    {
820        return Some(5_000 + common_bonus(entry));
821    }
822    if field_matches(&entry.summary, needle) {
823        return Some(4_000 + common_bonus(entry));
824    }
825    if entry.lines.iter().any(|line| field_matches(line, needle))
826        || entry.usage.as_ref().is_some_and(|usage| field_matches(usage, needle))
827        || entry.examples.iter().any(|example| {
828            field_matches(&example.command, needle) || field_matches(&example.description, needle)
829        })
830    {
831        return Some(3_000 + common_bonus(entry));
832    }
833    None
834}
835
836fn field_matches(value: &str, needle: &str) -> bool {
837    value.to_ascii_lowercase().contains(needle)
838}
839
840fn common_bonus(entry: &HelpEntry) -> i32 {
841    if entry.common { 100 } else { 0 }
842}
843
844fn mru_bonus(entry: &HelpEntry, recently_opened: &[String]) -> i32 {
845    recently_opened
846        .iter()
847        .position(|command| command == &entry.command)
848        .map(|idx| 50 - (idx as i32).min(49))
849        .unwrap_or(0)
850}
851
852pub fn highlight_segments(text: &str, query: &str) -> Vec<HighlightSegment> {
853    let needle = query.trim().to_ascii_lowercase();
854    if needle.is_empty() {
855        return vec![HighlightSegment { text: text.to_string(), matched: false }];
856    }
857
858    let lower = text.to_ascii_lowercase();
859    let mut segments = Vec::new();
860    let mut start = 0;
861    while let Some(relative) = lower[start..].find(&needle) {
862        let match_start = start + relative;
863        let match_end = match_start + needle.len();
864        if match_start > start {
865            segments.push(HighlightSegment { text: text[start..match_start].to_string(), matched: false });
866        }
867        segments.push(HighlightSegment { text: text[match_start..match_end].to_string(), matched: true });
868        start = match_end;
869    }
870    if start < text.len() {
871        segments.push(HighlightSegment { text: text[start..].to_string(), matched: false });
872    }
873    if segments.is_empty() {
874        segments.push(HighlightSegment { text: text.to_string(), matched: false });
875    }
876    segments
877}
878
879pub fn wrap_help_text(text: &str, width: usize) -> Vec<String> {
880    if width == 0 || text.len() <= width {
881        return vec![text.to_string()];
882    }
883
884    let indent: String = text.chars().take_while(|ch| ch.is_whitespace()).collect();
885    let content = text.trim_start();
886    if content.is_empty() {
887        return vec![text.to_string()];
888    }
889
890    let mut lines = Vec::new();
891    let mut current = indent.clone();
892    for word in content.split_whitespace() {
893        let separator = if current.trim().is_empty() { "" } else { " " };
894        let candidate_len = current.len() + separator.len() + word.len();
895        if candidate_len > width && current.trim().len() > 0 {
896            lines.push(current);
897            current = format!("{}{}", indent, word);
898        } else {
899            if !separator.is_empty() {
900                current.push(' ');
901            }
902            current.push_str(word);
903        }
904    }
905    if !current.trim().is_empty() {
906        lines.push(current);
907    }
908    if lines.is_empty() {
909        vec![text.to_string()]
910    } else {
911        lines
912    }
913}
914
915pub fn visible_help_find_window(row_heights: &[usize], cursor: usize, scroll: usize, visible_height: usize) -> usize {
916    if row_heights.is_empty() || visible_height == 0 {
917        return 0;
918    }
919    let cursor = cursor.min(row_heights.len() - 1);
920    let mut start = scroll.min(cursor);
921    while start < cursor && visual_height_between(row_heights, start, cursor) > visible_height {
922        start += 1;
923    }
924    start
925}
926
927pub fn wrap_help_find_entry_lines(command: &str, summary: &str, selected: bool, width: usize) -> Vec<(String, String)> {
928    let marker = if selected { "›" } else { " " };
929    let first_prefix = format!("{} ", marker);
930    let continuation_prefix = "    ";
931    let first_command_width = width.saturating_sub(first_prefix.chars().count()).min(22).max(8);
932    let command_lines = wrap_help_find_token(command, first_command_width);
933    let mut lines = Vec::new();
934
935    for (idx, command_part) in command_lines.iter().enumerate() {
936        if idx == 0 {
937            lines.push((format!("{}{}", first_prefix, command_part), String::new()));
938        } else {
939            lines.push((format!("{}{}", continuation_prefix, command_part), String::new()));
940        }
941    }
942
943    let summary_indent = "    ";
944    let summary_width = width.saturating_sub(summary_indent.len()).max(8);
945    let summary_lines = wrap_help_text(summary, summary_width);
946    for line in summary_lines {
947        lines.push((summary_indent.to_string(), line.trim_start().to_string()));
948    }
949
950    if lines.is_empty() {
951        vec![(first_prefix, String::new())]
952    } else {
953        lines
954    }
955}
956
957fn wrap_help_find_token(text: &str, width: usize) -> Vec<String> {
958    if width == 0 || text.chars().count() <= width {
959        return vec![text.to_string()];
960    }
961    let chars = text.chars().collect::<Vec<_>>();
962    let mut lines = Vec::new();
963    let mut start = 0;
964    while start < chars.len() {
965        let mut end = (start + width).min(chars.len());
966        if end < chars.len() {
967            if let Some(split) = chars[start..end]
968                .iter()
969                .rposition(|ch| matches!(ch, ':' | '-' | '_' | '/'))
970                .filter(|split| *split > 0)
971            {
972                end = start + split + 1;
973            }
974        }
975        lines.push(chars[start..end].iter().collect::<String>());
976        start = end;
977    }
978    lines
979}
980
981fn visual_height_between(row_heights: &[usize], start: usize, end_inclusive: usize) -> usize {
982    row_heights[start..=end_inclusive]
983        .iter()
984        .map(|height| (*height).max(1))
985        .sum()
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn builtin_help_json_loads() {
994        let entries = builtin_entries();
995        assert!(entries.iter().any(|entry| entry.command == "/help"));
996        assert!(entries.iter().any(|entry| entry.command == "/help find"));
997    }
998
999    #[test]
1000    fn wrap_help_text_wraps_words_to_width_and_preserves_indent() {
1001        let lines = wrap_help_text("  summary text should wrap neatly", 18);
1002
1003        assert_eq!(lines, vec!["  summary text", "  should wrap", "  neatly"]);
1004        assert!(lines.iter().all(|line| line.len() <= 18));
1005    }
1006
1007    #[test]
1008    fn wrap_help_text_returns_original_line_when_width_is_too_small() {
1009        let lines = wrap_help_text("abc def", 0);
1010
1011        assert_eq!(lines, vec!["abc def"]);
1012    }
1013
1014    #[test]
1015    fn visible_help_find_window_scrolls_by_visual_row_height_to_keep_cursor_visible() {
1016        let heights = vec![1, 1, 3, 1, 1];
1017
1018        assert_eq!(visible_help_find_window(&heights, 2, 0, 3), 2);
1019        assert_eq!(visible_help_find_window(&heights, 4, 2, 4), 3);
1020    }
1021
1022    #[test]
1023    fn wrap_help_find_entry_lines_wraps_commands_and_indents_wrapped_descriptions_lightly() {
1024        let lines = wrap_help_find_entry_lines(
1025            "/extension-showcase:very-long-demo-command",
1026            "description wraps onto a second visual line",
1027            false,
1028            34,
1029        );
1030
1031        assert!(lines.len() > 1);
1032        assert_eq!(lines[0].0, "  /extension-showcase:");
1033        assert!(lines[1].0.starts_with("    very-long-demo"), "wrapped command should indent slightly: {:?}", lines);
1034        assert_eq!(lines[2].0, "    ", "description should start on its own lightly indented line: {:?}", lines);
1035        assert_eq!(lines[2].1, "description wraps onto a");
1036        assert_eq!(lines[3].0, "    ", "wrapped description should keep small indent: {:?}", lines);
1037        assert_eq!(lines[3].1, "second visual line");
1038    }
1039}