synaps 0.1.0

Terminal-native AI agent runtime — parallel orchestration, reactive subagents, MCP, autonomous supervision
use synaps_cli::help::{HelpEntry, HelpFindState, HelpRegistry, HelpTopicKind};

fn test_entry(command: &str, title: &str, category: &str, common: bool) -> HelpEntry {
    HelpEntry {
        id: command.trim_start_matches('/').replace(' ', "-").to_string(),
        command: command.to_string(),
        title: title.to_string(),
        summary: format!("{} summary", title),
        category: category.to_string(),
        topic: HelpTopicKind::Command,
        protected: false,
        common,
        aliases: vec![],
        keywords: vec![],
        lines: vec![],
        usage: None,
        examples: vec![],
        related: vec![],
        source: None,
    }
}

fn plugin_entry(command: &str, title: &str, source: &str) -> HelpEntry {
    let mut entry = test_entry(command, title, "Plugin", false);
    entry.source = Some(source.to_string());
    entry
}

#[test]
fn help_find_places_help_commands_category_last_with_parent_command_first() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/help find", "Find Help", "Core", true),
            test_entry("/help", "Help", "Core", true),
            test_entry("/plugins", "Plugins Modal", "Plugins", true),
            test_entry("/model", "Model", "Models", true),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "");

    let rows = state.filtered_rows();
    let help_header_index = rows.iter().position(|row| row.category() == Some("Help commands")).unwrap();
    assert_eq!(help_header_index, rows.iter().rposition(|row| row.category().is_some()).unwrap());
    assert_eq!(rows[help_header_index + 1].entry().map(|entry| entry.command.as_str()), Some("/help"));
    assert_eq!(rows[help_header_index + 2].entry().map(|entry| entry.command.as_str()), Some("/help find"));
}

#[test]
fn help_find_places_extension_sections_below_help_commands_in_source_alphabetical_order() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/help", "Help", "Core", true),
            test_entry("/help find", "Find Help", "Core", true),
            test_entry("/plugins", "Plugins Modal", "Plugins", true),
        ],
        vec![
            plugin_entry("/zebra:demo", "Zebra Demo", "zebra-tools"),
            plugin_entry("/acme:sync", "Acme Sync", "acme-tools"),
            plugin_entry("/help acme", "Acme Help", "acme-tools"),
        ],
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "");

    let categories = state
        .filtered_rows()
        .into_iter()
        .filter_map(|row| row.category().map(str::to_string))
        .collect::<Vec<_>>();

    let help_index = categories.iter().position(|category| category == "Help commands").unwrap();
    let acme_index = categories.iter().position(|category| category == "Acme Tools").unwrap();
    let zebra_index = categories.iter().position(|category| category == "Zebra Tools").unwrap();

    assert!(help_index < acme_index, "extension sections should load below Help commands: {categories:?}");
    assert!(acme_index < zebra_index, "extension sections should be alphabetical: {categories:?}");
    assert!(!categories.contains(&"Plugin".to_string()), "plugin entries should not use generic Plugin header: {categories:?}");
}

#[test]
fn help_find_default_state_includes_help_topics_and_real_commands() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/help", "Help", "Core", true),
            test_entry("/help plugins", "Plugins Help", "Plugins", true),
            test_entry("/plugins", "Plugins Modal", "Plugins", true),
            test_entry("/model", "Model", "Models", true),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "");

    let commands = state
        .filtered_entries()
        .into_iter()
        .map(|entry| entry.command.as_str())
        .collect::<Vec<_>>();

    assert!(commands.contains(&"/help"));
    assert!(commands.contains(&"/help plugins"));
    assert!(commands.contains(&"/plugins"));
    assert!(commands.contains(&"/model"));
}

#[test]
fn help_find_groups_help_topics_under_help_commands_and_real_commands_under_their_theme() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/help plugins", "Plugins Help", "Plugins", true),
            test_entry("/plugins", "Plugins Modal", "Plugins", true),
            test_entry("/model", "Model", "Models", true),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "");

    let rows = state.filtered_rows();
    let help_header_index = rows.iter().position(|row| row.category() == Some("Help commands")).unwrap();
    let plugins_header_index = rows.iter().position(|row| row.category() == Some("Plugins")).unwrap();

    assert!(rows[help_header_index + 1].entry().is_some_and(|entry| entry.command == "/help plugins"));
    assert!(rows[plugins_header_index + 1].entry().is_some_and(|entry| entry.command == "/plugins"));
}

#[test]
fn help_find_empty_query_groups_each_category_once_even_when_ranking_interleaves_categories() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/core-common", "Core Common", "Core", true),
            test_entry("/plugin-common", "Plugin Common", "Plugins", true),
            test_entry("/core-rare", "Core Rare", "Core", false),
            test_entry("/plugin-rare", "Plugin Rare", "Plugins", false),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "");

    let categories = state
        .filtered_rows()
        .into_iter()
        .filter_map(|row| row.category().map(str::to_string))
        .collect::<Vec<_>>();

    assert_eq!(categories, vec!["Core", "Plugins"]);
}

#[test]
fn help_find_can_scope_to_help_commands_for_explicit_help_only_views() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/help", "Help", "Core", true),
            test_entry("/help plugins", "Plugins", "Plugins", true),
            test_entry("/plugins", "Plugins Modal", "Plugins", true),
            test_entry("/model", "Model", "Models", true),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new_help_commands(registry.entries().to_vec(), "");

    let commands = state
        .filtered_entries()
        .into_iter()
        .map(|entry| entry.command.as_str())
        .collect::<Vec<_>>();

    assert_eq!(commands, vec!["/help", "/help plugins"]);
}

#[test]
fn help_find_sections_group_empty_query_by_category_with_header_rows() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/zeta", "Zeta", "Advanced", false),
            test_entry("/settings", "Settings", "Settings", true),
            test_entry("/model", "Model", "Models", true),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "");

    let rows = state.filtered_rows();

    assert_eq!(rows[0].category(), Some("Models"));
    assert_eq!(rows[1].entry().map(|entry| entry.command.as_str()), Some("/model"));
    assert_eq!(rows[2].category(), Some("Settings"));
    assert_eq!(rows[3].entry().map(|entry| entry.command.as_str()), Some("/settings"));
    assert_eq!(rows[4].category(), Some("Advanced"));
    assert_eq!(rows[5].entry().map(|entry| entry.command.as_str()), Some("/zeta"));
}

#[test]
fn help_find_navigation_skips_category_headers() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/alpha", "Alpha", "Core", true),
            test_entry("/beta", "Beta", "Core", false),
        ],
        Vec::new(),
    );
    let mut state = HelpFindState::new(registry.entries().to_vec(), "");

    assert_eq!(state.cursor(), 1, "initial cursor should select first entry, not category header");
    assert_eq!(state.selected().map(|entry| entry.command.as_str()), Some("/alpha"));

    state.move_up();
    assert_eq!(state.selected().map(|entry| entry.command.as_str()), Some("/alpha"));

    state.move_down();
    assert_eq!(state.selected().map(|entry| entry.command.as_str()), Some("/beta"));
}

#[test]
fn help_find_section_headers_are_hidden_when_query_filters() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/model", "Model", "Models", true),
            test_entry("/settings", "Settings", "Settings", true),
        ],
        Vec::new(),
    );
    let state = HelpFindState::new(registry.entries().to_vec(), "model");

    let rows = state.filtered_rows();

    assert!(rows.iter().all(|row| row.entry().is_some()));
    assert_eq!(rows.len(), 1);
    assert_eq!(rows[0].entry().map(|entry| entry.command.as_str()), Some("/model"));
}

#[test]
fn help_find_highlight_spans_mark_query_matches_in_command_and_summary() {
    let mut entry = test_entry("/model", "Model", "Models", true);
    entry.summary = "Choose a model provider".to_string();

    let command_spans = synaps_cli::help::highlight_segments(&entry.command, "mod");
    assert_eq!(command_spans.len(), 3);
    assert_eq!(command_spans[0].text, "/");
    assert!(!command_spans[0].matched);
    assert_eq!(command_spans[1].text, "mod");
    assert!(command_spans[1].matched);
    assert_eq!(command_spans[2].text, "el");
    assert!(!command_spans[2].matched);

    let summary_spans = synaps_cli::help::highlight_segments(&entry.summary, "model");
    assert!(summary_spans.iter().any(|span| span.text == "model" && span.matched));
}

#[test]
fn help_find_mru_boosts_recently_opened_entry_without_beating_exact_command() {
    let registry = HelpRegistry::new(
        vec![
            test_entry("/model", "Model", "Models", false),
            test_entry("/modelist", "Modelist", "Advanced", false),
        ],
        Vec::new(),
    );
    let mut state = HelpFindState::new(registry.entries().to_vec(), "model");

    while state.selected().map(|entry| entry.command.as_str()) != Some("/modelist") {
        state.move_down();
    }
    state.open_selected();
    state.close_detail();

    let commands = state
        .filtered_entries()
        .into_iter()
        .map(|entry| entry.command.as_str())
        .collect::<Vec<_>>();

    assert_eq!(commands[..2], ["/model", "/modelist"], "exact command stays first");

    state.clear_filter();
    state.push_char('m');
    state.push_char('o');
    state.push_char('d');
    state.push_char('e');
    state.push_char('l');
    state.push_char('i');

    let commands = state
        .filtered_entries()
        .into_iter()
        .map(|entry| entry.command.as_str())
        .collect::<Vec<_>>();
    assert_eq!(commands.first().copied(), Some("/modelist"));
}