use crate::types::{ErrorSeverity, ErrorTag, RpcErrorType};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum NetconfError {
#[error("transport error: {0}")]
Transport(#[from] TransportError),
#[error("framing error: {0}")]
Framing(#[from] FramingError),
#[error("RPC error: {0}")]
Rpc(#[from] RpcError),
#[error("protocol error: {0}")]
Protocol(#[from] ProtocolError),
}
#[derive(Debug, Error)]
pub enum TransportError {
#[error("connection failed: {0}")]
Connect(String),
#[error("authentication failed: {0}")]
Auth(String),
#[error("channel error: {0}")]
Channel(String),
#[error("channel closed: {0}")]
ChannelClosed(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("SSH error: {0}")]
Ssh(String),
#[cfg(feature = "tls")]
#[error("TLS error: {0}")]
Tls(String),
#[error("host key mismatch for {host}: known_hosts has {expected}, server presented {actual}")]
HostKeyMismatch {
host: String,
expected: String,
actual: String,
},
#[error("host {host}:{port} not found in known_hosts file {path}")]
HostKeyNotInKnownHosts {
host: String,
port: u16,
path: String,
},
#[error("host key for {host} is marked @revoked in known_hosts")]
HostKeyRevoked { host: String },
}
#[derive(Debug, Error)]
pub enum FramingError {
#[error("invalid frame: {0}")]
Invalid(String),
#[error("incomplete frame: expected {expected} bytes, got {actual}")]
Incomplete { expected: usize, actual: usize },
#[error("framing mismatch: device advertised NETCONF {advertised} but sent {actual}-style frames. Try forcing the other version.")]
Mismatch { advertised: String, actual: String },
}
#[derive(Debug, Error)]
pub enum RpcError {
#[error("server error: [{tag:?}] {message}")]
ServerError {
error_type: Option<RpcErrorType>,
tag: ErrorTag,
severity: Option<ErrorSeverity>,
app_tag: Option<String>,
path: Option<String>,
message: String,
info: Option<String>,
},
#[error("RPC timeout after {0:?}")]
Timeout(std::time::Duration),
#[error("commit status unknown: connection lost after sending <commit>. The device may have committed the change — verify device state.")]
CommitUnknown,
#[error("failed to parse RPC response: {0}")]
ParseError(String),
#[error("message-id mismatch: expected {expected}, got {actual}")]
MessageIdMismatch { expected: String, actual: String },
}
#[derive(Debug, Error)]
pub enum ProtocolError {
#[error("capability not supported: {0}")]
CapabilityMissing(String),
#[error("session is closed")]
SessionClosed,
#[error("session expired: keepalive probe failed")]
SessionExpired,
#[error("hello exchange failed: {0}")]
HelloFailed(String),
#[error("XML error: {0}")]
Xml(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_key_mismatch_display_includes_all_fields() {
let err = TransportError::HostKeyMismatch {
host: "device-a.lab".into(),
expected: "SHA256:aaa".into(),
actual: "SHA256:bbb".into(),
};
let s = err.to_string();
assert!(s.contains("device-a.lab"), "got {s:?}");
assert!(s.contains("SHA256:aaa"), "got {s:?}");
assert!(s.contains("SHA256:bbb"), "got {s:?}");
}
#[test]
fn host_key_not_in_known_hosts_display_includes_port_and_path() {
let err = TransportError::HostKeyNotInKnownHosts {
host: "device-a.lab".into(),
port: 830,
path: "/etc/jmcp/known_hosts".into(),
};
let s = err.to_string();
assert!(s.contains("device-a.lab"), "got {s:?}");
assert!(s.contains("830"), "got {s:?}");
assert!(s.contains("/etc/jmcp/known_hosts"), "got {s:?}");
}
#[test]
fn host_key_revoked_display_includes_host() {
let err = TransportError::HostKeyRevoked {
host: "device-a.lab".into(),
};
assert!(err.to_string().contains("device-a.lab"));
assert!(err.to_string().contains("revoked"));
}
}