ssh-cli 0.5.5

Native Rust CLI that gives LLMs (Claude Code, Cursor, Windsurf) the ability to operate remote servers via SSH over stdin/stdout
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// G-COMP: unit tests extracted for line budget.
#![forbid(unsafe_code)]

use super::*;

#[test]
fn vps_not_found_message_contains_name() {
    let err = SshCliError::VpsNotFound("prod".into());
    assert!(err.to_string().contains("prod"));
}

#[test]
fn vps_duplicate_message_contains_name() {
    let err = SshCliError::VpsDuplicate("vps-1".into());
    assert!(err.to_string().contains("already exists"));
}

#[test]
fn exit_code_auth_failed_is_noperm() {
    assert_eq!(
        SshCliError::AuthenticationFailed.exit_code(),
        exit_codes::EX_NOPERM
    );
}

#[test]
fn exit_code_command_failed_is_general_not_remote() {
    let e = SshCliError::CommandFailed {
        exit_code: 64,
        stderr: "usage".into(),
    };
    assert_eq!(e.exit_code(), exit_codes::EX_GENERAL);
}

#[test]
fn exit_code_broken_pipe_is_141() {
    let e = SshCliError::Io(std::io::Error::new(
        std::io::ErrorKind::BrokenPipe,
        "pipe closed",
    ));
    assert_eq!(e.exit_code(), exit_codes::EX_PIPE);
    assert_eq!(exit_codes::EX_PIPE, 141);
}

#[test]
fn exit_code_other_io_is_74() {
    let e = SshCliError::Io(std::io::Error::other("disk full"));
    assert_eq!(e.exit_code(), exit_codes::EX_IOERR);
}

#[test]
fn anyhow_detects_broken_pipe() {
    let err: anyhow::Error =
        SshCliError::Io(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "epipe")).into();
    assert!(anyhow_is_broken_pipe(&err));
}

#[test]
fn connection_failed_is_retryable_transient() {
    let e = SshCliError::ConnectionFailed("refused".into());
    assert!(e.is_retryable());
    assert!(!e.is_permanent());
    assert_eq!(e.classify(), ErrorClass::Transient);
    assert_eq!(e.layer(), ErrorLayer::Network);
    assert_eq!(e.exit_code(), exit_codes::EX_IOERR);
    assert!(e.suggestion().is_some());
    assert_eq!(e.error_code(), "connection_failed");
}

#[test]
fn auth_failed_is_permanent_not_retryable() {
    let e = SshCliError::AuthenticationFailed;
    assert!(!e.is_retryable());
    assert!(e.is_permanent());
    assert_eq!(e.classify(), ErrorClass::Permanent);
    assert_eq!(e.layer(), ErrorLayer::Auth);
    assert_eq!(e.exit_code(), exit_codes::EX_NOPERM);
}

#[test]
fn command_failed_not_transport_retryable() {
    let e = SshCliError::CommandFailed {
        exit_code: 1,
        stderr: "boom".into(),
    };
    assert!(!e.is_retryable());
    assert_eq!(e.classify(), ErrorClass::Permanent);
    assert_eq!(e.retry_kind(), RetryKind::PermanentRemoteCommand);
}

#[test]
fn timeout_is_retryable() {
    let e = SshCliError::SshTimeout(5_000);
    assert!(e.is_retryable());
    assert_eq!(e.retry_kind(), RetryKind::TransientTimeout);
}

#[test]
fn broken_pipe_is_cancelled() {
    let e = SshCliError::Io(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe"));
    assert!(!e.is_retryable());
    assert!(!e.is_permanent());
    assert_eq!(e.classify(), ErrorClass::Cancelled);
    assert_eq!(e.retry_kind(), RetryKind::Cancelled);
}

#[test]
fn classification_never_uses_display_string() {
    // Guard: variants with similar messages still classify by type.
    let a = SshCliError::ConnectionFailed("authentication failed".into());
    let b = SshCliError::AuthenticationFailed;
    assert!(a.is_retryable());
    assert!(!b.is_retryable());
}

#[test]
fn display_messages_are_lowercase_without_trailing_period() {
    let samples = [
        SshCliError::VpsNotFound("x".into()).to_string(),
        SshCliError::NoActiveVps.to_string(),
        SshCliError::tls_msg("handshake").to_string(),
        SshCliError::channel_msg("open").to_string(),
        SshCliError::Io(std::io::Error::other("x")).to_string(),
    ];
    for s in samples {
        assert!(!s.ends_with('.'), "trailing period: {s}");
        let first = s.chars().next().unwrap();
        assert!(
            !first.is_ascii_uppercase(),
            "Display should start lowercase: {s}"
        );
    }
}

#[test]
fn tls_src_preserves_source_chain() {
    let io = std::io::Error::other("root-cause");
    let e = SshCliError::tls_src("handshake failed", io);
    let src = std::error::Error::source(&e).expect("source");
    assert!(src.to_string().contains("root-cause"));
    assert!(!e.to_string().contains("root-cause"));
}

#[test]
fn domain_maps_to_domain_variant() {
    let d = crate::domain::DomainError::new("port", "out of range");
    let e: SshCliError = d.into();
    assert!(matches!(e, SshCliError::Domain(_)));
    assert_eq!(e.error_code(), "domain_validation");
    assert_eq!(e.exit_code(), exit_codes::EX_USAGE);
}