osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use super::*;
use chrono::{TimeZone, Utc};

fn history_config() -> HistoryConfigBuilder {
    HistoryConfig::builder()
        .with_enabled(true)
        .with_max_entries(10)
        .with_dedupe(false)
        .with_profile_scoped(false)
}

#[test]
fn wildcard_matching_handles_prefix_and_infix() {
    assert!(matches_pattern("ldap user *", "ldap user bob"));
    assert!(matches_pattern("*token*", "auth token read"));
    assert!(!matches_pattern("auth", "auth token"));
    assert!(matches_pattern("auth*", "auth token"));
    assert!(matches_pattern("*user", "ldap user"));
    assert!(!matches_pattern("*user", "ldap user bob"));
}

#[test]
fn excluded_commands_respect_prefixes_and_patterns() {
    let excludes = vec![
        "help".to_string(),
        "exit".to_string(),
        "quit".to_string(),
        "history list".to_string(),
    ];
    assert!(is_excluded_command("help", &excludes));
    assert!(is_excluded_command("history list", &excludes));
    assert!(!is_excluded_command("history prune 10", &[]));
    assert!(is_excluded_command("ldap user --help", &[]));
    assert!(is_excluded_command(
        "login oistes",
        &[String::from("login *")]
    ));
}

#[test]
fn list_entries_filters_shell_and_excludes() {
    let shell = HistoryShellContext::new("ldap");
    let config = history_config()
        .with_exclude_patterns(["user *"])
        .with_shell_context(shell)
        .build();
    let mut store = OspHistoryStore::new(config);
    let _ = History::save(
        &mut store,
        HistoryItem::from_command_line("ldap user alice"),
    )
    .expect("save should succeed");
    let _ = History::save(
        &mut store,
        HistoryItem::from_command_line("ldap netgroup ucore"),
    )
    .expect("save should succeed");
    let _ = History::save(&mut store, HistoryItem::from_command_line("mreg host a"))
        .expect("save should succeed");

    let entries = store.list_entries();
    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].command, "netgroup ucore");
    assert_eq!(entries[1].command, "mreg host a");
}

#[test]
fn list_entries_tracks_live_shell_context_updates() {
    let shell = HistoryShellContext::default();
    let config = history_config().with_shell_context(shell.clone()).build();
    let mut store = OspHistoryStore::new(config);
    let _ = History::save(
        &mut store,
        HistoryItem::from_command_line("ldap user alice"),
    )
    .expect("save should succeed");
    let _ = History::save(&mut store, HistoryItem::from_command_line("mreg host a"))
        .expect("save should succeed");

    shell.set_prefix("ldap");
    let ldap_entries = store.list_entries();
    assert_eq!(ldap_entries.len(), 1);
    assert_eq!(ldap_entries[0].command, "user alice");

    shell.set_prefix("mreg");
    let mreg_entries = store.list_entries();
    assert_eq!(mreg_entries.len(), 1);
    assert_eq!(mreg_entries[0].command, "host a");

    shell.clear();
    let root_entries = store.list_entries();
    assert_eq!(root_entries.len(), 2);
}

#[test]
fn explicit_scope_queries_override_live_shell_context() {
    let shell = HistoryShellContext::default();
    let config = history_config().with_shell_context(shell.clone()).build();
    let mut store = OspHistoryStore::new(config);
    let _ = History::save(
        &mut store,
        HistoryItem::from_command_line("ldap user alice"),
    )
    .expect("save should succeed");
    let _ = History::save(&mut store, HistoryItem::from_command_line("mreg host a"))
        .expect("save should succeed");

    shell.set_prefix("ldap");
    let mreg_entries = store.list_entries_for(Some("mreg"));
    assert_eq!(mreg_entries.len(), 1);
    assert_eq!(mreg_entries[0].command, "host a");

    let removed = store
        .prune_for(0, Some("mreg"))
        .expect("prune should succeed");
    assert_eq!(removed, 1);

    let root_entries = store.list_entries_for(None);
    assert_eq!(root_entries.len(), 1);
    assert_eq!(root_entries[0].command, "ldap user alice");
}

#[test]
fn save_expands_history_and_dedupes_with_shell_scope() {
    let shell = HistoryShellContext::new("ldap");
    let config = history_config()
        .with_dedupe(true)
        .with_shell_context(shell)
        .build();
    let mut store = OspHistoryStore::new(config);

    let first = History::save(&mut store, HistoryItem::from_command_line("user alice"))
        .expect("save should succeed");
    assert_eq!(first.command_line, "ldap user alice");

    let duplicate = History::save(&mut store, HistoryItem::from_command_line("!!"))
        .expect("history expansion should succeed");
    assert_eq!(duplicate.command_line, "!!");
    assert_eq!(store.list_entries().len(), 1);

    let second = History::save(&mut store, HistoryItem::from_command_line("netgroup ops"))
        .expect("save should succeed");
    assert_eq!(second.command_line, "ldap netgroup ops");

    let recent = store.recent_commands();
    assert_eq!(recent, vec!["ldap user alice", "ldap netgroup ops"]);
    let visible = store.list_entries();
    assert_eq!(visible[0].command, "user alice");
    assert_eq!(visible[1].command, "netgroup ops");
}

#[test]
fn search_respects_filters_direction_bounds_and_skip_logic() {
    let config = history_config()
        .with_shell_context(HistoryShellContext::default())
        .build();
    let mut store = OspHistoryStore::new(config);

    let mut first = HistoryItem::from_command_line("ldap user alice");
    first.cwd = Some("/srv/ldap".to_string());
    first.hostname = Some("ops-a".to_string());
    first.exit_status = Some(0);
    first.start_timestamp = Some(Utc.timestamp_millis_opt(1_000).single().unwrap());
    History::save(&mut store, first).expect("save should succeed");

    let mut second = HistoryItem::from_command_line("ldap user bob");
    second.cwd = Some("/srv/ldap/cache".to_string());
    second.hostname = Some("ops-b".to_string());
    second.exit_status = Some(1);
    second.start_timestamp = Some(Utc.timestamp_millis_opt(2_000).single().unwrap());
    History::save(&mut store, second).expect("save should succeed");

    let mut third = HistoryItem::from_command_line("mreg host a");
    third.cwd = Some("/srv/mreg".to_string());
    third.hostname = Some("ops-a".to_string());
    third.exit_status = Some(0);
    third.start_timestamp = Some(Utc.timestamp_millis_opt(3_000).single().unwrap());
    History::save(&mut store, third).expect("save should succeed");

    let mut filter = SearchFilter::anything(None);
    filter.command_line = Some(CommandLineSearch::Prefix("ldap".to_string()));
    filter.cwd_prefix = Some("/srv/ldap".to_string());
    filter.exit_successful = Some(true);
    filter.hostname = Some("ops-a".to_string());

    let forward = SearchQuery {
        direction: SearchDirection::Forward,
        start_time: Some(Utc.timestamp_millis_opt(500).single().unwrap()),
        end_time: Some(Utc.timestamp_millis_opt(1_500).single().unwrap()),
        start_id: None,
        end_id: Some(HistoryItemId::new(2)),
        limit: Some(5),
        filter,
    };
    let results = store.search(forward).expect("search should succeed");
    assert_eq!(results.len(), 1);
    assert_eq!(results[0].command_line, "ldap user alice");

    let mut backward = SearchQuery::everything(SearchDirection::Backward, None);
    backward.start_id = Some(HistoryItemId::new(1));
    backward.limit = Some(2);
    let results = store.search(backward).expect("search should succeed");
    let commands = results
        .iter()
        .map(|item| item.command_line.as_str())
        .collect::<Vec<_>>();
    assert_eq!(commands, vec!["ldap user alice"]);
    assert_eq!(
        store
            .count(SearchQuery::everything(SearchDirection::Forward, None))
            .expect("count should succeed"),
        3
    );
}

#[test]
fn persisted_records_skip_invalid_lines_and_trim_to_capacity() {
    let temp_dir = make_temp_dir("osp-repl-history-load");
    let path = temp_dir.join("history.jsonl");
    std::fs::write(
        &path,
        concat!(
            "\n",
            "{\"id\":5,\"command_line\":\"first\",\"timestamp_ms\":10}\n",
            "not-json\n",
            "{\"id\":6,\"command_line\":\"   \",\"timestamp_ms\":20}\n",
            "{\"id\":7,\"command_line\":\"second\",\"timestamp_ms\":30}\n"
        ),
    )
    .expect("history fixture should be written");

    let store = OspHistoryStore::new(
        history_config()
            .with_path(Some(path))
            .with_max_entries(1)
            .with_shell_context(HistoryShellContext::default())
            .build(),
    );

    let entries = store.list_entries_for(None);
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].id, 1);
    assert_eq!(entries[0].command, "second");
}

#[test]
fn shared_history_supports_save_load_prune_clear_and_sync() {
    let temp_dir = make_temp_dir("osp-repl-shared-history");
    let path = temp_dir.join("history.jsonl");
    let mut history = SharedHistory::new(
        history_config()
            .with_path(Some(path.clone()))
            .with_max_entries(8)
            .with_shell_context(HistoryShellContext::default())
            .build(),
    );

    history
        .save_command_line("config show")
        .expect("save should succeed");
    history
        .save_command_line("config get ui.format")
        .expect("save should succeed");
    assert!(history.enabled());
    assert_eq!(history.recent_commands().len(), 2);
    assert_eq!(
        history
            .load(HistoryItemId::new(0))
            .expect("load should succeed")
            .command_line,
        "config show"
    );

    assert_eq!(history.prune(1).expect("prune should succeed"), 1);
    assert_eq!(history.list_entries().len(), 1);
    history.sync().expect("sync should succeed");
    assert!(path.exists());
    assert_eq!(history.clear_for(None).expect("clear should succeed"), 1);
    assert!(history.list_entries().is_empty());
    History::clear(&mut history).expect("clear should succeed");
    assert!(!path.exists());
}

#[test]
fn shell_prefix_helpers_normalize_and_round_trip_commands() {
    assert_eq!(
        normalize_shell_prefix(" ldap ".to_string()),
        Some("ldap ".to_string())
    );
    assert_eq!(
        normalize_scope_prefix(Some("ldap")),
        Some("ldap ".to_string())
    );
    assert!(command_matches_shell_prefix(
        "ldap user alice",
        Some("ldap ")
    ));
    assert_eq!(
        apply_shell_prefix("user alice", Some("ldap ")),
        "ldap user alice"
    );
    assert_eq!(
        apply_shell_prefix("ldap user alice", Some("ldap ")),
        "ldap user alice"
    );
    assert_eq!(
        strip_shell_prefix("ldap user alice", Some("ldap ")),
        "user alice"
    );
}

#[test]
fn unsupported_history_mutations_surface_feature_errors() {
    let mut store = OspHistoryStore::new(
        history_config()
            .with_max_entries(4)
            .with_shell_context(HistoryShellContext::default())
            .build(),
    );

    let update_err = store
        .update(HistoryItemId::new(0), &|item| item)
        .expect_err("update should stay unsupported");
    let delete_err = store
        .delete(HistoryItemId::new(0))
        .expect_err("delete should stay unsupported");

    assert!(update_err.to_string().contains("updating entries"));
    assert!(delete_err.to_string().contains("removing entries"));
    assert_eq!(store.session(), None);
}

#[test]
fn load_missing_history_item_returns_not_found_error() {
    let store = OspHistoryStore::new(
        history_config()
            .with_max_entries(4)
            .with_shell_context(HistoryShellContext::default())
            .build(),
    );

    let err = store
        .load(HistoryItemId::new(7))
        .expect_err("missing entry should fail");
    assert!(err.to_string().contains("history item not found"));
}

#[test]
fn disabled_history_returns_original_item_without_persisting_records() {
    let mut store = OspHistoryStore::new(
        history_config()
            .with_enabled(false)
            .with_dedupe(true)
            .with_shell_context(HistoryShellContext::default())
            .build(),
    );

    let item = History::save(
        &mut store,
        HistoryItem::from_command_line("ldap user alice"),
    )
    .expect("disabled history should be a no-op");

    assert_eq!(item.command_line, "ldap user alice");
    assert!(store.list_entries().is_empty());
    assert!(store.recent_commands().is_empty());
}

fn make_temp_dir(prefix: &str) -> crate::tests::TestTempDir {
    crate::tests::make_temp_dir(prefix)
}