diff --git a/Cargo.toml b/Cargo.toml index 931b058..b551fbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,8 @@ tracing = { version = "0.1.41", optional = true } [build-dependencies] prost-build = "0.14.1" + +[[bin]] +name = "kicad-ipc-cli" +path = "test-scripts/kicad-ipc-cli.rs" +required-features = ["async"] diff --git a/src/blocking.rs b/src/blocking.rs index a9837ca..033c4f1 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -11,7 +11,7 @@ impl KiCadClientBlocking { let runtime = tokio::runtime::Builder::new_current_thread() .enable_time() .build() - .map_err(|err| KiCadError::Connection(err.to_string()))?; + .map_err(|err| KiCadError::RuntimeJoin(err.to_string()))?; let inner = runtime.block_on(KiCadClient::connect())?; Ok(Self { inner }) } diff --git a/src/client.rs b/src/client.rs index a3f116b..63db39e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,16 +1,43 @@ -use std::time::Duration; +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use crate::envelope; use crate::error::KiCadError; +use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo, VersionInfo}; +use crate::proto::kiapi::common::commands as common_commands; +use crate::proto::kiapi::common::types as common_types; +use crate::transport::Transport; + +const KICAD_API_SOCKET_ENV: &str = "KICAD_API_SOCKET"; +const KICAD_API_TOKEN_ENV: &str = "KICAD_API_TOKEN"; + +const CMD_PING: &str = "kiapi.common.commands.Ping"; +const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion"; +const CMD_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments"; + +const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; +const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; #[derive(Clone, Debug)] pub struct KiCadClient { - config: ClientConfig, + inner: Arc, +} + +#[derive(Debug)] +struct ClientInner { + transport: Transport, + token: Mutex, + client_name: String, + timeout: Duration, + socket_uri: String, } #[derive(Clone, Debug)] struct ClientConfig { timeout: Duration, - socket_path: Option, + socket_uri: Option, token: Option, client_name: Option, } @@ -25,7 +52,7 @@ impl ClientBuilder { Self { config: ClientConfig { timeout: Duration::from_millis(3_000), - socket_path: None, + socket_uri: None, token: None, client_name: None, }, @@ -38,7 +65,7 @@ impl ClientBuilder { } pub fn socket_path(mut self, socket_path: impl Into) -> Self { - self.config.socket_path = Some(socket_path.into()); + self.config.socket_uri = Some(socket_path.into()); self } @@ -53,8 +80,33 @@ impl ClientBuilder { } pub async fn connect(self) -> Result { + let socket_uri = resolve_socket_uri(self.config.socket_uri.as_deref()); + if is_missing_ipc_socket(&socket_uri) { + return Err(KiCadError::SocketUnavailable { socket_uri }); + } + + let timeout = self.config.timeout; + let transport = Transport::connect(&socket_uri, timeout)?; + + let token = self + .config + .token + .or_else(|| std::env::var(KICAD_API_TOKEN_ENV).ok()) + .unwrap_or_default(); + + let client_name = self + .config + .client_name + .unwrap_or_else(default_client_name); + Ok(KiCadClient { - config: self.config, + inner: Arc::new(ClientInner { + transport, + token: Mutex::new(token), + client_name, + timeout, + socket_uri, + }), }) } } @@ -75,25 +127,305 @@ impl KiCadClient { } pub fn timeout(&self) -> Duration { - self.config.timeout + self.inner.timeout } + + pub fn socket_uri(&self) -> &str { + &self.inner.socket_uri + } + + pub async fn ping(&self) -> Result<(), KiCadError> { + let command = envelope::pack_any(&common_commands::Ping {}, CMD_PING); + self.send_command(command).await?; + Ok(()) + } + + pub async fn get_version(&self) -> Result { + let command = envelope::pack_any(&common_commands::GetVersion {}, CMD_GET_VERSION); + let response = self.send_command(command).await?; + + let payload: common_commands::GetVersionResponse = + envelope::unpack_any(&response, RES_GET_VERSION)?; + + let version = payload + .version + .ok_or_else(|| KiCadError::MissingPayload { + expected_type_url: "kiapi.common.types.KiCadVersion".to_string(), + })?; + + Ok(VersionInfo { + major: version.major, + minor: version.minor, + patch: version.patch, + full_version: version.full_version, + }) + } + + pub async fn get_open_documents( + &self, + document_type: DocumentType, + ) -> Result, KiCadError> { + let command = common_commands::GetOpenDocuments { + r#type: document_type.to_proto(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_OPEN_DOCUMENTS)) + .await?; + + let payload: common_commands::GetOpenDocumentsResponse = + envelope::unpack_any(&response, RES_GET_OPEN_DOCUMENTS)?; + + Ok(payload + .documents + .into_iter() + .filter_map(map_document_specifier) + .collect()) + } + + pub async fn get_current_project_path(&self) -> Result { + let docs = self.get_open_documents(DocumentType::Pcb).await?; + select_single_project_path(&docs) + } + + pub async fn has_open_board(&self) -> Result { + let docs = self.get_open_documents(DocumentType::Pcb).await?; + Ok(!docs.is_empty()) + } + + async fn send_command( + &self, + command: prost_types::Any, + ) -> Result { + let token = self + .inner + .token + .lock() + .map_err(|_| KiCadError::InternalPoisoned)? + .clone(); + + let request_bytes = envelope::encode_request(&token, &self.inner.client_name, command)?; + + let transport = self.inner.clone(); + let response_bytes = tokio::task::spawn_blocking(move || { + transport.transport.roundtrip(request_bytes.as_slice()) + }) + .await + .map_err(|err| KiCadError::RuntimeJoin(err.to_string()))??; + + let response = envelope::decode_response(&response_bytes)?; + + if let Some(err) = envelope::status_error(&response) { + return Err(err); + } + + if token.is_empty() { + if let Some(header) = response.header.as_ref() { + if !header.kicad_token.is_empty() { + let mut guard = self + .inner + .token + .lock() + .map_err(|_| KiCadError::InternalPoisoned)?; + *guard = header.kicad_token.clone(); + } + } + } + + Ok(response) + } +} + +fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option { + let document_type = DocumentType::from_proto(source.r#type)?; + let board_filename = match source.identifier { + Some(common_types::document_specifier::Identifier::BoardFilename(filename)) => { + Some(filename) + } + _ => None, + }; + + let project = source.project.unwrap_or_default(); + + let project_info = ProjectInfo { + name: if project.name.is_empty() { + None + } else { + Some(project.name) + }, + path: if project.path.is_empty() { + None + } else { + Some(PathBuf::from(project.path)) + }, + }; + + Some(DocumentSpecifier { + document_type, + board_filename, + project: project_info, + }) +} + +fn select_single_project_path(docs: &[DocumentSpecifier]) -> Result { + let mut paths = BTreeSet::new(); + for doc in docs { + if let Some(path) = doc.project.path.as_ref() { + paths.insert(path.display().to_string()); + } + } + + if paths.is_empty() { + return Err(KiCadError::BoardNotOpen); + } + + if paths.len() > 1 { + return Err(KiCadError::AmbiguousProjectPath { + paths: paths.into_iter().collect(), + }); + } + + let first = paths.into_iter().next().ok_or(KiCadError::BoardNotOpen)?; + Ok(PathBuf::from(first)) +} + +fn resolve_socket_uri(explicit: Option<&str>) -> String { + if let Some(socket) = explicit { + return normalize_socket_uri(socket); + } + + if let Ok(socket) = std::env::var(KICAD_API_SOCKET_ENV) { + if !socket.is_empty() { + return normalize_socket_uri(&socket); + } + } + + normalize_socket_uri(default_socket_path().to_string_lossy().as_ref()) +} + +fn default_socket_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + return std::env::temp_dir().join("kicad").join("api.sock"); + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(home) = std::env::var_os("HOME") { + let flatpak = PathBuf::from(home) + .join(".var") + .join("app") + .join("org.kicad.KiCad") + .join("cache") + .join("tmp") + .join("kicad") + .join("api.sock"); + if flatpak.exists() { + return flatpak; + } + } + + PathBuf::from("/tmp/kicad/api.sock") + } +} + +fn normalize_socket_uri(socket: &str) -> String { + if socket.contains("://") { + return socket.to_string(); + } + + format!("ipc://{socket}") +} + +fn ipc_path_from_uri(socket_uri: &str) -> Option { + let raw_path = socket_uri.strip_prefix("ipc://")?; + Some(PathBuf::from(raw_path)) +} + +fn is_missing_ipc_socket(socket_uri: &str) -> bool { + if let Some(path) = ipc_path_from_uri(socket_uri) { + return !path.exists(); + } + + false +} + +fn default_client_name() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + + format!("kicad-ipc-{}-{millis}", std::process::id()) } #[cfg(test)] mod tests { - use std::time::Duration; + use super::{normalize_socket_uri, select_single_project_path}; + use crate::error::KiCadError; + use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo}; + use std::path::PathBuf; - use super::ClientBuilder; + #[test] + fn normalize_socket_uri_adds_ipc_scheme() { + let normalized = normalize_socket_uri("/tmp/kicad/api.sock"); + assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); + } - #[tokio::test] - async fn builder_overrides_timeout() { - let timeout = Duration::from_secs(9); - let client = ClientBuilder::new() - .timeout(timeout) - .connect() - .await - .expect("builder should connect in baseline scaffold"); + #[test] + fn normalize_socket_uri_preserves_existing_scheme() { + let normalized = normalize_socket_uri("ipc:///tmp/kicad/api.sock"); + assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); + } - assert_eq!(client.timeout(), timeout); + #[test] + fn select_single_project_path_picks_unique_path() { + let docs = vec![DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("demo.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("demo".to_string()), + path: Some(PathBuf::from("/tmp/demo")), + }, + }]; + + let result = select_single_project_path(&docs) + .expect("a single project path should be selected when exactly one path exists"); + assert_eq!(result, PathBuf::from("/tmp/demo")); + } + + #[test] + fn select_single_project_path_errors_on_ambiguity() { + let docs = vec![ + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("a.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("a".to_string()), + path: Some(PathBuf::from("/tmp/a")), + }, + }, + DocumentSpecifier { + document_type: DocumentType::Pcb, + board_filename: Some("b.kicad_pcb".to_string()), + project: ProjectInfo { + name: Some("b".to_string()), + path: Some(PathBuf::from("/tmp/b")), + }, + }, + ]; + + let result = select_single_project_path(&docs); + assert!(matches!( + result, + Err(KiCadError::AmbiguousProjectPath { .. }) + )); + } + + #[test] + fn select_single_project_path_requires_open_board() { + let docs: Vec = Vec::new(); + let result = select_single_project_path(&docs); + assert!(matches!(result, Err(KiCadError::BoardNotOpen))); } } diff --git a/src/envelope.rs b/src/envelope.rs index b3c7809..2eae4d9 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -1,2 +1,113 @@ -#[derive(Debug, Default)] -pub struct Envelope; +use prost::Message; +use prost_types::Any; + +use crate::error::KiCadError; +use crate::proto::kiapi::common::{ + ApiRequest, ApiRequestHeader, ApiResponse, ApiStatusCode, +}; + +pub(crate) fn type_url(type_name: &str) -> String { + format!("type.googleapis.com/{type_name}") +} + +pub(crate) fn pack_any(message: &T, type_name: &str) -> Any { + Any { + type_url: type_url(type_name), + value: message.encode_to_vec(), + } +} + +pub(crate) fn unpack_any( + response: &ApiResponse, + expected_type_name: &str, +) -> Result { + let expected_type_url = type_url(expected_type_name); + let payload = response + .message + .as_ref() + .ok_or_else(|| KiCadError::MissingPayload { + expected_type_url: expected_type_url.clone(), + })?; + + if payload.type_url != expected_type_url { + return Err(KiCadError::UnexpectedPayloadType { + expected_type_url, + actual_type_url: payload.type_url.clone(), + }); + } + + T::decode(payload.value.as_slice()) + .map_err(|err| KiCadError::ProtobufDecode(err.to_string())) +} + +pub(crate) fn encode_request( + token: &str, + client_name: &str, + command: Any, +) -> Result, KiCadError> { + let request = ApiRequest { + header: Some(ApiRequestHeader { + kicad_token: token.to_string(), + client_name: client_name.to_string(), + }), + message: Some(command), + }; + + Ok(request.encode_to_vec()) +} + +pub(crate) fn decode_response(bytes: &[u8]) -> Result { + ApiResponse::decode(bytes).map_err(|err| KiCadError::ProtobufDecode(err.to_string())) +} + +pub(crate) fn status_error(response: &ApiResponse) -> Option { + let status = response.status.as_ref()?; + let code = ApiStatusCode::try_from(status.status).unwrap_or(ApiStatusCode::AsUnknown); + + if code == ApiStatusCode::AsOk { + return None; + } + + Some(KiCadError::ApiStatus { + code: code.as_str_name().to_string(), + message: status.error_message.clone(), + }) +} + +#[cfg(test)] +mod tests { + use crate::proto::kiapi::common::{ApiResponse, ApiResponseStatus}; + + use super::status_error; + + #[test] + fn status_error_returns_none_for_ok() { + let response = ApiResponse { + header: None, + status: Some(ApiResponseStatus { + status: 1, + error_message: String::new(), + }), + message: None, + }; + + assert!(status_error(&response).is_none()); + } + + #[test] + fn status_error_returns_error_for_non_ok() { + let response = ApiResponse { + header: None, + status: Some(ApiResponseStatus { + status: 6, + error_message: "token mismatch".to_string(), + }), + message: None, + }; + + let err = status_error(&response) + .expect("non-ok API status should map to KiCadError::ApiStatus"); + let message = err.to_string(); + assert!(message.contains("AS_TOKEN_MISMATCH")); + } +} diff --git a/src/error.rs b/src/error.rs index 0cb9937..52b06bb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,13 +1,54 @@ +use std::time::Duration; + use thiserror::Error; #[derive(Debug, Error)] pub enum KiCadError { - #[error("connection failed: {0}")] - Connection(String), - #[error("invalid configuration: {0}")] - Config(String), - #[error("transport error: {0}")] - Transport(String), - #[error("protocol error: {0}")] - Protocol(String), + #[error("invalid configuration: {reason}")] + Config { reason: String }, + + #[error("KiCad IPC socket not available at `{socket_uri}`. Open KiCad and open a project/board first.")] + SocketUnavailable { socket_uri: String }, + + #[error("connection failed for `{socket_uri}`: {reason}")] + Connection { socket_uri: String, reason: String }, + + #[error("transport send failed: {reason}")] + TransportSend { reason: String }, + + #[error("transport receive failed: {reason}")] + TransportReceive { reason: String }, + + #[error("request timed out after {timeout:?}")] + Timeout { timeout: Duration }, + + #[error("API status error `{code}`: {message}")] + ApiStatus { code: String, message: String }, + + #[error("API response missing payload for `{expected_type_url}`")] + MissingPayload { expected_type_url: String }, + + #[error("unexpected payload type; expected `{expected_type_url}`, got `{actual_type_url}`")] + UnexpectedPayloadType { + expected_type_url: String, + actual_type_url: String, + }, + + #[error("protobuf encode failed: {0}")] + ProtobufEncode(String), + + #[error("protobuf decode failed: {0}")] + ProtobufDecode(String), + + #[error("runtime task join failed: {0}")] + RuntimeJoin(String), + + #[error("mutex poisoned")] + InternalPoisoned, + + #[error("no open PCB document found; open a board in KiCad first")] + BoardNotOpen, + + #[error("multiple project paths found across open PCB docs: {paths:?}")] + AmbiguousProjectPath { paths: Vec }, } diff --git a/src/lib.rs b/src/lib.rs index a4dfb76..fb607a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,23 @@ //! Async-first Rust bindings for the KiCad IPC API. //! -//! This crate is intentionally layered: +//! Layering: //! - transport //! - envelope //! - command builders //! - high-level client pub mod client; +pub mod commands; pub mod envelope; pub mod error; pub mod model; pub mod transport; -pub mod commands; - #[cfg(feature = "blocking")] pub mod blocking; +pub(crate) mod proto; + pub use crate::client::{ClientBuilder, KiCadClient}; pub use crate::error::KiCadError; +pub use crate::model::common::{DocumentSpecifier, DocumentType, VersionInfo}; diff --git a/src/model/common.rs b/src/model/common.rs index 9c5f819..88c4391 100644 --- a/src/model/common.rs +++ b/src/model/common.rs @@ -1,3 +1,8 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use crate::proto::kiapi::common::types as common_types; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct VersionInfo { pub major: u32, @@ -5,3 +10,85 @@ pub struct VersionInfo { pub patch: u32, pub full_version: String, } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DocumentType { + Schematic, + Symbol, + Pcb, + Footprint, + DrawingSheet, + Project, +} + +impl DocumentType { + pub(crate) fn to_proto(self) -> i32 { + match self { + Self::Schematic => common_types::DocumentType::DoctypeSchematic as i32, + Self::Symbol => common_types::DocumentType::DoctypeSymbol as i32, + Self::Pcb => common_types::DocumentType::DoctypePcb as i32, + Self::Footprint => common_types::DocumentType::DoctypeFootprint as i32, + Self::DrawingSheet => common_types::DocumentType::DoctypeDrawingSheet as i32, + Self::Project => common_types::DocumentType::DoctypeProject as i32, + } + } + + pub(crate) fn from_proto(value: i32) -> Option { + let ty = common_types::DocumentType::try_from(value).ok()?; + match ty { + common_types::DocumentType::DoctypeSchematic => Some(Self::Schematic), + common_types::DocumentType::DoctypeSymbol => Some(Self::Symbol), + common_types::DocumentType::DoctypePcb => Some(Self::Pcb), + common_types::DocumentType::DoctypeFootprint => Some(Self::Footprint), + common_types::DocumentType::DoctypeDrawingSheet => Some(Self::DrawingSheet), + common_types::DocumentType::DoctypeProject => Some(Self::Project), + common_types::DocumentType::DoctypeUnknown => None, + } + } +} + +impl std::fmt::Display for DocumentType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Schematic => "schematic", + Self::Symbol => "symbol", + Self::Pcb => "pcb", + Self::Footprint => "footprint", + Self::DrawingSheet => "drawing-sheet", + Self::Project => "project", + }; + + write!(f, "{value}") + } +} + +impl FromStr for DocumentType { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "schematic" => Ok(Self::Schematic), + "symbol" => Ok(Self::Symbol), + "pcb" => Ok(Self::Pcb), + "footprint" => Ok(Self::Footprint), + "drawing-sheet" => Ok(Self::DrawingSheet), + "project" => Ok(Self::Project), + _ => Err(format!( + "unknown document type `{value}`; expected one of: schematic, symbol, pcb, footprint, drawing-sheet, project" + )), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectInfo { + pub name: Option, + pub path: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DocumentSpecifier { + pub document_type: DocumentType, + pub board_filename: Option, + pub project: ProjectInfo, +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs new file mode 100644 index 0000000..40fb8d8 --- /dev/null +++ b/src/proto/mod.rs @@ -0,0 +1,38 @@ +pub(crate) mod kiapi { + #[allow(dead_code)] + pub mod common { + include!(concat!(env!("OUT_DIR"), "/kiapi.common.rs")); + + pub mod commands { + include!(concat!(env!("OUT_DIR"), "/kiapi.common.commands.rs")); + } + + pub mod project { + include!(concat!(env!("OUT_DIR"), "/kiapi.common.project.rs")); + } + + pub mod types { + include!(concat!(env!("OUT_DIR"), "/kiapi.common.types.rs")); + } + } + + #[allow(dead_code)] + pub mod board { + include!(concat!(env!("OUT_DIR"), "/kiapi.board.rs")); + + pub mod commands { + include!(concat!(env!("OUT_DIR"), "/kiapi.board.commands.rs")); + } + + pub mod types { + include!(concat!(env!("OUT_DIR"), "/kiapi.board.types.rs")); + } + } + + #[allow(dead_code)] + pub mod schematic { + pub mod types { + include!(concat!(env!("OUT_DIR"), "/kiapi.schematic.types.rs")); + } + } +} diff --git a/src/transport.rs b/src/transport.rs index c274294..3ad8e2c 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -1,2 +1,83 @@ -#[derive(Debug, Default)] -pub struct Transport; +use std::sync::Mutex; +use std::time::Duration; + +use nng::options::{Options, RecvTimeout, SendTimeout}; +use nng::{Error as NngError, Protocol, Socket}; + +use crate::error::KiCadError; + +#[derive(Debug)] +pub(crate) struct Transport { + socket: Mutex, + timeout: Duration, +} + +impl Transport { + pub(crate) fn connect(socket_uri: &str, timeout: Duration) -> Result { + let socket = Socket::new(Protocol::Req0).map_err(|err| KiCadError::Connection { + socket_uri: socket_uri.to_string(), + reason: err.to_string(), + })?; + + socket + .set_opt::(Some(timeout)) + .map_err(|err| KiCadError::Connection { + socket_uri: socket_uri.to_string(), + reason: err.to_string(), + })?; + + socket + .set_opt::(Some(timeout)) + .map_err(|err| KiCadError::Connection { + socket_uri: socket_uri.to_string(), + reason: err.to_string(), + })?; + + socket.dial(socket_uri).map_err(|err| KiCadError::Connection { + socket_uri: socket_uri.to_string(), + reason: err.to_string(), + })?; + + Ok(Self { + socket: Mutex::new(socket), + timeout, + }) + } + + pub(crate) fn roundtrip(&self, request_bytes: &[u8]) -> Result, KiCadError> { + let guard = self + .socket + .lock() + .map_err(|_| KiCadError::InternalPoisoned)?; + + guard + .send(request_bytes) + .map_err(|(_, err)| map_send_error(err, self.timeout))?; + + let response = guard + .recv() + .map_err(|err| map_receive_error(err, self.timeout))?; + + Ok(response.as_slice().to_vec()) + } +} + +fn map_send_error(error: NngError, timeout: Duration) -> KiCadError { + if error == NngError::TimedOut { + return KiCadError::Timeout { timeout }; + } + + KiCadError::TransportSend { + reason: error.to_string(), + } +} + +fn map_receive_error(error: NngError, timeout: Duration) -> KiCadError { + if error == NngError::TimedOut { + return KiCadError::Timeout { timeout }; + } + + KiCadError::TransportReceive { + reason: error.to_string(), + } +} diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs new file mode 100644 index 0000000..82e9d90 --- /dev/null +++ b/test-scripts/kicad-ipc-cli.rs @@ -0,0 +1,220 @@ +use std::process::ExitCode; +use std::str::FromStr; +use std::time::Duration; + +use kicad_ipc::{ClientBuilder, DocumentType, KiCadError}; + +#[derive(Debug)] +struct CliConfig { + socket: Option, + token: Option, + timeout_ms: u64, +} + +#[derive(Debug)] +enum Command { + Ping, + Version, + OpenDocs { document_type: DocumentType }, + ProjectPath, + BoardOpen, + Smoke, + Help, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err}"); + if matches!(err, KiCadError::BoardNotOpen | KiCadError::SocketUnavailable { .. }) { + eprintln!( + "hint: launch KiCad, open a project, and open a PCB editor window before rerunning this command." + ); + } + if let KiCadError::ApiStatus { code, message } = &err { + if code == "AS_UNHANDLED" { + eprintln!( + "hint: this KiCad build reported the command as unavailable (`{message}`). try `ping` and `version`, or update KiCad/API settings." + ); + } + } + ExitCode::from(1) + } + } +} + +async fn run() -> Result<(), KiCadError> { + let (config, command) = parse_args()?; + + if matches!(command, Command::Help) { + print_help(); + return Ok(()); + } + + let mut builder = ClientBuilder::new().timeout(Duration::from_millis(config.timeout_ms)); + if let Some(socket) = config.socket { + builder = builder.socket_path(socket); + } + if let Some(token) = config.token { + builder = builder.token(token); + } + + let client = builder.connect().await?; + + match command { + Command::Ping => { + client.ping().await?; + println!("pong"); + } + Command::Version => { + let version = client.get_version().await?; + println!( + "version: {}.{}.{} ({})", + version.major, version.minor, version.patch, version.full_version + ); + } + Command::OpenDocs { document_type } => { + let docs = client.get_open_documents(document_type).await?; + if docs.is_empty() { + println!("no open `{document_type}` documents"); + } else { + for (idx, doc) in docs.iter().enumerate() { + let board = doc.board_filename.as_deref().unwrap_or("-"); + let project_name = doc.project.name.as_deref().unwrap_or("-"); + let project_path = doc + .project + .path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "-".to_string()); + + println!( + "[{idx}] type={} board={} project_name={} project_path={}", + doc.document_type, board, project_name, project_path + ); + } + } + } + Command::ProjectPath => { + let path = client.get_current_project_path().await?; + println!("project_path={}", path.display()); + } + Command::BoardOpen => { + let has_board = client.has_open_board().await?; + if has_board { + println!("board-open: yes"); + } else { + return Err(KiCadError::BoardNotOpen); + } + } + Command::Smoke => { + client.ping().await?; + let version = client.get_version().await?; + let has_board = client.has_open_board().await?; + println!( + "smoke ok: version={}.{}.{} board_open={}", + version.major, version.minor, version.patch, has_board + ); + } + Command::Help => print_help(), + } + + Ok(()) +} + +fn parse_args() -> Result<(CliConfig, Command), KiCadError> { + let mut args: Vec = std::env::args().skip(1).collect(); + + if args.is_empty() { + return Ok((default_config(), Command::Help)); + } + + let mut config = default_config(); + let mut index = 0; + + while index < args.len() { + match args[index].as_str() { + "--socket" => { + let value = args.get(index + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for --socket".to_string(), + })?; + config.socket = Some(value.clone()); + args.drain(index..=index + 1); + } + "--token" => { + let value = args.get(index + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for --token".to_string(), + })?; + config.token = Some(value.clone()); + args.drain(index..=index + 1); + } + "--timeout-ms" => { + let value = args.get(index + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for --timeout-ms".to_string(), + })?; + config.timeout_ms = value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid --timeout-ms value `{value}`: {err}"), + })?; + args.drain(index..=index + 1); + } + _ => { + index += 1; + } + } + } + + if args.is_empty() { + return Ok((config, Command::Help)); + } + + let command = match args[0].as_str() { + "help" | "--help" | "-h" => Command::Help, + "ping" => Command::Ping, + "version" => Command::Version, + "project-path" => Command::ProjectPath, + "board-open" => Command::BoardOpen, + "smoke" => Command::Smoke, + "open-docs" => { + let mut document_type = DocumentType::Pcb; + let mut i = 1; + while i < args.len() { + if args[i] == "--type" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for open-docs --type".to_string(), + })?; + document_type = + DocumentType::from_str(value).map_err(|err| KiCadError::Config { + reason: err, + })?; + i += 2; + continue; + } + i += 1; + } + Command::OpenDocs { document_type } + } + other => { + return Err(KiCadError::Config { + reason: format!("unknown command `{other}`"), + }); + } + }; + + Ok((config, command)) +} + +fn default_config() -> CliConfig { + CliConfig { + socket: None, + token: None, + timeout_ms: 3_000, + } +} + +fn print_help() { + println!( + "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" + ); +}