rskit-cli 0.1.0-alpha.1

CLI framework: progress bars, structured output, signal handling
Documentation
use std::time::Duration;

use rskit_cli::{
    CancellationToken, ErrorRenderer, ExitCode, MultiProgress, OutputFormat, OutputKV, OutputTable,
    ProgressBar, ProgressStyle,
};
use rskit_errors::{AppError, ErrorCode};

#[test]
fn exit_code_mapping_covers_public_error_categories() {
    let cases = [
        (ErrorCode::InvalidInput, ExitCode::Usage),
        (ErrorCode::InvalidFormat, ExitCode::Usage),
        (ErrorCode::MissingField, ExitCode::Usage),
        (ErrorCode::Unauthorized, ExitCode::Permission),
        (ErrorCode::Forbidden, ExitCode::Permission),
        (ErrorCode::TokenExpired, ExitCode::Permission),
        (ErrorCode::InvalidToken, ExitCode::Permission),
        (ErrorCode::NotFound, ExitCode::NotFound),
        (ErrorCode::Conflict, ExitCode::Conflict),
        (ErrorCode::AlreadyExists, ExitCode::Conflict),
        (ErrorCode::ServiceUnavailable, ExitCode::Unavailable),
        (ErrorCode::ConnectionFailed, ExitCode::Unavailable),
        (ErrorCode::ExternalService, ExitCode::Unavailable),
        (ErrorCode::RateLimited, ExitCode::RateLimited),
        (ErrorCode::Timeout, ExitCode::Timeout),
        (ErrorCode::Cancelled, ExitCode::Cancelled),
        (ErrorCode::Internal, ExitCode::Failure),
    ];

    for (error_code, exit_code) in cases {
        assert_eq!(ExitCode::from(error_code), exit_code);
        assert_eq!(exit_code.as_i32(), exit_code as i32);
    }
}

#[test]
fn error_renderer_outputs_text_json_and_yaml_with_details() {
    let error = AppError::new(ErrorCode::RateLimited, "too many requests")
        .with_detail("bucket", "search")
        .retryable(true);

    let (text, text_code) = ErrorRenderer::default().render(&error);
    assert_eq!(text_code, ExitCode::RateLimited);
    assert_eq!(text, "error[RATE_LIMITED]: too many requests");

    let (json, json_code) = ErrorRenderer::new(OutputFormat::Json).render(&error);
    assert_eq!(json_code, ExitCode::RateLimited);
    let payload: serde_json::Value = serde_json::from_str(&json).unwrap();
    assert_eq!(payload["code"], "RATE_LIMITED");
    assert_eq!(payload["message"], "too many requests");
    assert_eq!(payload["retryable"], true);
    assert_eq!(payload["exit_code"], 75);
    assert_eq!(payload["details"]["bucket"], "search");

    let (yaml, yaml_code) = ErrorRenderer::new(OutputFormat::Yaml).render(&error);
    assert_eq!(yaml_code, ExitCode::RateLimited);
    assert!(yaml.contains("code: RATE_LIMITED"));
    assert!(yaml.contains("bucket: search"));
}

#[test]
fn table_and_key_value_outputs_handle_empty_and_wide_rows() {
    let empty = OutputTable::new(vec!["Name", "Status"])
        .with_title("Release readiness")
        .to_string();
    assert!(empty.contains("Release readiness"));
    assert!(empty.contains("Name"));
    assert!(empty.contains("Status"));

    let mut table = OutputTable::new(vec!["Name"]);
    table.add_row(vec!["short", "extra cell ignored by width calculation"]);
    let output = table.to_string();
    assert!(output.contains("short"));
    assert!(output.contains("extra cell ignored by width calculation"));

    assert_eq!(OutputKV::default().to_string(), "");
    let mut kv = OutputKV::new();
    kv.add("Short", "yes").add("MuchLongerKey", "aligned");
    let kv_output = kv.to_string();
    assert!(kv_output.contains("        Short:  yes"));
    assert!(kv_output.contains("MuchLongerKey:  aligned"));
}

#[test]
fn progress_bar_wrapper_updates_position_length_message_and_finish_state() {
    let bar = ProgressBar::new(10, ProgressStyle::Bar);
    assert_eq!(bar.inner().length(), Some(10));

    bar.set_prefix("download");
    bar.set_message("starting");
    bar.inc(3);
    bar.tick();
    assert_eq!(bar.inner().position(), 3);
    assert_eq!(bar.inner().prefix(), "download");
    assert_eq!(bar.inner().message(), "starting");

    bar.set_style(ProgressStyle::Download);
    bar.enable_steady_tick(Duration::from_millis(1));
    bar.set_length(20);
    bar.set_position(12);
    assert_eq!(bar.inner().length(), Some(20));
    assert_eq!(bar.inner().position(), 12);

    bar.finish_with_message("done");
    assert!(bar.inner().is_finished());
    assert_eq!(bar.inner().message(), "done");

    bar.reset();
    assert_eq!(bar.inner().position(), 0);
    bar.finish_and_clear();
}

#[test]
fn spinner_and_multi_progress_cover_ordering_static_lines_and_removal() {
    let spinner = ProgressBar::spinner();
    spinner.set_prefix("spin");
    spinner.set_style(ProgressStyle::Spinner);
    spinner.finish();
    assert!(spinner.inner().is_finished());

    let multi = MultiProgress::new();
    multi.println("starting").unwrap();
    let first = multi.add_bar("first", 2);
    let second = multi.insert_bar_after(&first, "second", 3);
    let spinner = multi.add_spinner("waiting");
    let static_line = multi.add_static_line("ok", "completed");

    first.inc(1);
    second.set_position(2);
    spinner.tick();

    assert_eq!(first.inner().position(), 1);
    assert_eq!(second.inner().position(), 2);
    assert!(static_line.inner().is_finished());
    assert_eq!(static_line.inner().message(), "completed");

    multi.remove(&spinner);
    multi.clear().unwrap();

    let raw = rskit_cli::progress::RawMultiProgress::new();
    let wrapped = MultiProgress::from_raw(raw);
    wrapped.clear().unwrap();
    let _ = wrapped.raw();
}

#[tokio::test]
async fn cancellation_token_default_clone_and_waiter_observe_cancel() {
    let token = CancellationToken::default();
    let waiter = token.clone();
    let task = tokio::spawn(async move {
        waiter.cancelled().await;
        waiter.is_cancelled()
    });

    assert!(!token.is_cancelled());
    token.cancel();
    assert!(task.await.unwrap());
}