diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index f11d229..1ea22dd 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -79,7 +79,7 @@ Write current net classes back with selected merge mode: cargo run --features blocking --bin kicad-ipc-cli -- set-net-classes --merge-mode merge ``` -List text variables for current board document: +List text variables for current project: ```bash cargo run --features blocking --bin kicad-ipc-cli -- text-variables @@ -400,7 +400,7 @@ Notes: - Report output is intentionally capped for very large boards to avoid multi-GB files. - For full raw payloads, use targeted commands such as `items-raw --debug`, `pad-shape-polygon --debug`, and `padstack-presence --debug`. -Get current project path (derived from open PCB docs): +Get current project path (from open PCB docs, or `KIPRJMOD` when `GetOpenDocuments` is unavailable): ```bash cargo run --features blocking --bin kicad-ipc-cli -- project-path diff --git a/src/client.rs b/src/client.rs index 6b7b01a..5542405 100644 --- a/src/client.rs +++ b/src/client.rs @@ -34,6 +34,7 @@ use crate::transport::Transport; const KICAD_API_SOCKET_ENV: &str = "KICAD_API_SOCKET"; const KICAD_API_TOKEN_ENV: &str = "KICAD_API_TOKEN"; +const KIPRJMOD_ENV: &str = "KIPRJMOD"; const CMD_PING: &str = "kiapi.common.commands.Ping"; const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion"; @@ -536,7 +537,7 @@ impl KiCadClient { pub async fn get_text_variables_raw(&self) -> Result { let command = common_commands::GetTextVariables { - document: Some(self.current_board_document_proto().await?), + document: Some(project_document_proto()), }; let response = self .send_command(envelope::pack_any(&command, CMD_GET_TEXT_VARIABLES)) @@ -557,7 +558,7 @@ impl KiCadClient { merge_mode: MapMergeMode, ) -> Result { let command = common_commands::SetTextVariables { - document: Some(self.current_board_document_proto().await?), + document: Some(project_document_proto()), variables: Some(common_project::TextVariables { variables: variables.into_iter().collect(), }), @@ -584,7 +585,7 @@ impl KiCadClient { text: Vec, ) -> Result { let command = common_commands::ExpandTextVariables { - document: Some(self.current_board_document_proto().await?), + document: Some(project_document_proto()), text, }; let response = self @@ -667,12 +668,13 @@ impl KiCadClient { .collect() } - /// Returns the current PCB project's path. + /// Returns the current project path. /// - /// Fails if no PCB is open or if multiple project paths are present. + /// First queries open PCB documents. If KiCad reports `GetOpenDocuments` as unhandled, + /// this falls back to the `KIPRJMOD` environment variable when available. pub async fn get_current_project_path(&self) -> Result { - let docs = self.get_open_documents(DocumentType::Pcb).await?; - select_single_project_path(&docs) + let docs = self.get_open_documents(DocumentType::Pcb).await; + resolve_current_project_path(docs) } /// Returns `true` when at least one PCB document is open in KiCad. @@ -2079,6 +2081,14 @@ fn model_document_to_proto(document: &DocumentSpecifier) -> common_types::Docume } } +fn project_document_proto() -> common_types::DocumentSpecifier { + common_types::DocumentSpecifier { + r#type: DocumentType::Project.to_proto(), + project: Some(common_types::ProjectSpecifier::default()), + identifier: None, + } +} + fn text_spec_to_proto(text: TextSpec) -> common_types::Text { common_types::Text { position: text.position_nm.map(vector2_nm_to_proto), @@ -3705,6 +3715,35 @@ fn select_single_project_path(docs: &[DocumentSpecifier]) -> Result, KiCadError>, +) -> Result { + match docs_result { + Ok(docs) => select_single_project_path(&docs), + Err(err) if is_get_open_documents_unhandled(&err) => { + project_path_from_environment().ok_or(err) + } + Err(err) => Err(err), + } +} + +fn project_path_from_environment() -> Option { + let value = std::env::var(KIPRJMOD_ENV).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + + Some(PathBuf::from(trimmed)) +} + +fn is_get_open_documents_unhandled(err: &KiCadError) -> bool { + matches!( + err, + KiCadError::ApiStatus { code, .. } if code == "AS_UNHANDLED" + ) +} + fn resolve_socket_uri(explicit: Option<&str>) -> String { if let Some(socket) = explicit { return normalize_socket_uri(socket); @@ -3781,13 +3820,14 @@ mod tests { any_to_pretty_debug, board_editor_appearance_settings_to_proto, board_stackup_to_proto, commit_action_to_proto, decode_pcb_item, drc_severity_to_proto, ensure_item_deletion_status_ok, ensure_item_request_ok, ensure_item_status_ok, - layer_to_model, map_board_stackup, map_commit_session, map_hit_test_result, - map_item_bounding_boxes, map_merge_mode_to_proto, map_polygon_with_holes, - map_run_action_status, model_document_to_proto, normalize_socket_uri, - pad_netlist_from_footprint_items, response_payload_as_any, select_single_board_document, - select_single_project_path, selection_item_detail, summarize_item_details, - summarize_selection, text_horizontal_alignment_to_proto, text_spec_to_proto, - PCB_OBJECT_TYPES, + is_get_open_documents_unhandled, layer_to_model, map_board_stackup, map_commit_session, + map_hit_test_result, map_item_bounding_boxes, map_merge_mode_to_proto, + map_polygon_with_holes, map_run_action_status, model_document_to_proto, + normalize_socket_uri, pad_netlist_from_footprint_items, project_document_proto, + project_path_from_environment, resolve_current_project_path, response_payload_as_any, + select_single_board_document, select_single_project_path, selection_item_detail, + summarize_item_details, summarize_selection, text_horizontal_alignment_to_proto, + text_spec_to_proto, KIPRJMOD_ENV, PCB_OBJECT_TYPES, }; use crate::error::KiCadError; use crate::model::board::{ @@ -3799,6 +3839,9 @@ mod tests { }; use prost::Message; use std::path::PathBuf; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); #[test] fn normalize_socket_uri_adds_ipc_scheme() { @@ -3812,6 +3855,13 @@ mod tests { assert_eq!(normalized, "ipc:///tmp/kicad/api.sock"); } + #[test] + fn project_document_proto_uses_project_type() { + let document = project_document_proto(); + assert_eq!(document.r#type, DocumentType::Project.to_proto()); + assert!(document.identifier.is_none()); + } + #[test] fn select_single_project_path_picks_unique_path() { let docs = vec![DocumentSpecifier { @@ -3863,6 +3913,74 @@ mod tests { assert!(matches!(result, Err(KiCadError::BoardNotOpen))); } + #[test] + fn resolve_current_project_path_reads_env_when_open_docs_unhandled() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::set_var(KIPRJMOD_ENV, "/tmp/kicad-env-project"); + + let result = resolve_current_project_path(Err(KiCadError::ApiStatus { + code: "AS_UNHANDLED".to_string(), + message: + "no handler available for request of type kiapi.common.commands.GetOpenDocuments" + .to_string(), + })) + .expect("KIPRJMOD fallback should resolve project path"); + + assert_eq!(result, PathBuf::from("/tmp/kicad-env-project")); + std::env::remove_var(KIPRJMOD_ENV); + } + + #[test] + fn resolve_current_project_path_keeps_original_error_without_env() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::remove_var(KIPRJMOD_ENV); + + let err = resolve_current_project_path(Err(KiCadError::ApiStatus { + code: "AS_UNHANDLED".to_string(), + message: + "no handler available for request of type kiapi.common.commands.GetOpenDocuments" + .to_string(), + })) + .expect_err("without env fallback should keep original unhandled error"); + + assert!(matches!(err, KiCadError::ApiStatus { .. })); + } + + #[test] + fn resolve_current_project_path_does_not_fallback_when_no_board_docs() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::set_var(KIPRJMOD_ENV, "/tmp/kicad-env-project"); + + let err = resolve_current_project_path(Ok(Vec::new())) + .expect_err("no-board docs should remain BoardNotOpen"); + assert!(matches!(err, KiCadError::BoardNotOpen)); + + std::env::remove_var(KIPRJMOD_ENV); + } + + #[test] + fn project_path_from_environment_ignores_empty_values() { + let _guard = ENV_MUTEX.lock().expect("env mutex should lock"); + std::env::set_var(KIPRJMOD_ENV, " "); + assert!(project_path_from_environment().is_none()); + std::env::remove_var(KIPRJMOD_ENV); + } + + #[test] + fn is_get_open_documents_unhandled_matches_expected_shape() { + let unhandled = KiCadError::ApiStatus { + code: "AS_UNHANDLED".to_string(), + message: String::new(), + }; + assert!(is_get_open_documents_unhandled(&unhandled)); + + let other = KiCadError::ApiStatus { + code: "AS_BAD_REQUEST".to_string(), + message: "bad request".to_string(), + }; + assert!(!is_get_open_documents_unhandled(&other)); + } + #[test] fn select_single_board_document_errors_on_multiple_open_boards() { let docs = vec![ diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index 75c349a..303e8fc 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -2110,14 +2110,14 @@ COMMANDS: plugin-settings-path [--identifier ] Resolve writeable plugin settings directory (default: kicad-ipc-rust) open-docs [--type ] List open docs (default type: pcb) - project-path Get current project path from open PCB docs + project-path Get current project path from open PCB docs (or KIPRJMOD fallback) board-open Exit non-zero if no PCB doc is open net-classes List project netclass definitions set-net-classes [--merge-mode ] Write current netclass set back with selected merge mode - text-variables List text variables for current board document + text-variables List text variables for current project set-text-variables [--merge-mode ] [--var ...] - Set text variables for current board document + Set text variables for current project expand-text-variables Expand variables in provided text values Options: --text (repeatable) text-extents Measure text bounding box