From 9fc116ab6cb9193483820ee53db8184743f38939 Mon Sep 17 00:00:00 2001 From: Sublime Git Bot Date: Fri, 12 Dec 2025 17:01:09 +0000 Subject: [PATCH] feat(WOR-TSK-199): implement execute command types for Node.js bindings - Add ExecuteParams with timeout and filtering options - Add PackageExecutionResult for per-package execution results - Add ExecuteSummary for aggregate execution statistics - Add ExecuteData as main response structure - Add ExecuteApiResponse for NAPI-compatible responses - Update types/mod.rs with execute type re-exports - Add 41 comprehensive tests for all execute types - Update dependency versions (cli: 0.0.31, pkg: 0.0.22) - Regenerate NAPI bindings with new execute types - Export execute types from packages/workspace-tools/src/index.ts Story 6.2: Implement Execute Types --- crates/node/Cargo.lock | 4 +- crates/node/Cargo.toml | 4 +- crates/node/src/tests.rs | 605 ++++++++ crates/node/src/types/execute.rs | 1289 ++++++++++++++++- crates/node/src/types/mod.rs | 17 +- .../npm/darwin-arm64/package.json | 2 +- .../npm/darwin-x64/package.json | 2 +- .../npm/linux-arm64-gnu/package.json | 2 +- .../npm/linux-arm64-musl/package.json | 2 +- .../npm/linux-x64-gnu/package.json | 2 +- .../npm/linux-x64-musl/package.json | 2 +- .../npm/win32-arm64-msvc/package.json | 2 +- .../npm/win32-x64-msvc/package.json | 2 +- packages/workspace-tools/package.json | 2 +- packages/workspace-tools/src/binding.d.ts | 491 +++++++ packages/workspace-tools/src/binding.js | 104 +- packages/workspace-tools/src/index.ts | 50 +- 17 files changed, 2457 insertions(+), 125 deletions(-) diff --git a/crates/node/Cargo.lock b/crates/node/Cargo.lock index 9d879c02..e55663c0 100644 --- a/crates/node/Cargo.lock +++ b/crates/node/Cargo.lock @@ -2067,7 +2067,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "sublime_cli_tools" -version = "0.0.29" +version = "0.0.31" dependencies = [ "anyhow", "chrono", @@ -2131,7 +2131,7 @@ dependencies = [ [[package]] name = "sublime_pkg_tools" -version = "0.0.20" +version = "0.0.22" dependencies = [ "async-trait", "base64 0.21.7", diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 1a1fbc0e..8b3fe568 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -69,9 +69,9 @@ napi = { version = "3.6.0", features = ["async", "tokio_rt", "napi9", "serde-jso napi-derive = "3.4.0" # Workspace crates - using path dependencies -sublime_cli_tools = { version = "0.0.29", path = "../cli" } +sublime_cli_tools = { version = "0.0.31", path = "../cli" } sublime_git_tools = { version = "0.0.15", path = "../git" } -sublime_pkg_tools = { version = "0.0.20", path = "../pkg" } +sublime_pkg_tools = { version = "0.0.22", path = "../pkg" } sublime_standard_tools = { version = "0.0.15", path = "../standard" } # Serialization diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index 4092ecb1..8a0feb97 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -4081,3 +4081,608 @@ mod bump_types_tests { assert!(json.contains("\"snapshot_version\":\"1.0.0-snapshot.abc\"")); } } + +// ============================================================================= +// Execute Types Tests (Story 6.2) +// ============================================================================= + +/// Tests for execute command type definitions. +#[cfg(test)] +mod execute_types_tests { + use crate::error::ErrorInfo; + use crate::types::execute::{ + ExecuteApiResponse, ExecuteData, ExecuteParams, ExecuteSummary, PackageExecutionResult, + }; + + // ======================================================================== + // ExecuteParams Tests + // ======================================================================== + + #[test] + fn test_execute_params_new() { + let params = ExecuteParams::new("/workspace", "npm:test"); + + assert_eq!(params.root, "/workspace"); + assert_eq!(params.cmd, "npm:test"); + assert!(params.filter_package.is_none()); + assert!(params.affected.is_none()); + assert!(params.since.is_none()); + assert!(params.until.is_none()); + assert!(params.branch.is_none()); + assert!(params.parallel.is_none()); + assert!(params.args.is_none()); + assert!(params.timeout_secs.is_none()); + assert!(params.per_package_timeout_secs.is_none()); + } + + #[test] + fn test_execute_params_builder_chain() { + let params = ExecuteParams::new("/workspace", "npm:test") + .with_filter_package(vec!["@scope/core".to_string()]) + .with_parallel(true) + .with_timeout_secs(300) + .with_per_package_timeout_secs(60) + .with_args(vec!["--coverage".to_string()]); + + assert_eq!(params.root, "/workspace"); + assert_eq!(params.cmd, "npm:test"); + assert_eq!(params.filter_package, Some(vec!["@scope/core".to_string()])); + assert_eq!(params.parallel, Some(true)); + assert_eq!(params.timeout_secs, Some(300)); + assert_eq!(params.per_package_timeout_secs, Some(60)); + assert_eq!(params.args, Some(vec!["--coverage".to_string()])); + } + + #[test] + fn test_execute_params_affected_options() { + let params = ExecuteParams::new("/workspace", "npm:test") + .with_affected(true) + .with_branch("main") + .with_since("HEAD~5") + .with_until("HEAD"); + + assert_eq!(params.affected, Some(true)); + assert_eq!(params.branch, Some("main".to_string())); + assert_eq!(params.since, Some("HEAD~5".to_string())); + assert_eq!(params.until, Some("HEAD".to_string())); + } + + #[test] + fn test_execute_params_has_filter_package() { + let params_none = ExecuteParams::new(".", "npm:test"); + assert!(!params_none.has_filter_package()); + + let params_empty = ExecuteParams::new(".", "npm:test").with_filter_package(vec![]); + assert!(!params_empty.has_filter_package()); + + let params_with_packages = + ExecuteParams::new(".", "npm:test").with_filter_package(vec!["pkg".to_string()]); + assert!(params_with_packages.has_filter_package()); + } + + #[test] + fn test_execute_params_is_affected() { + let params_none = ExecuteParams::new(".", "npm:test"); + assert!(!params_none.is_affected()); + + let params_false = ExecuteParams::new(".", "npm:test").with_affected(false); + assert!(!params_false.is_affected()); + + let params_true = ExecuteParams::new(".", "npm:test").with_affected(true); + assert!(params_true.is_affected()); + } + + #[test] + fn test_execute_params_is_parallel() { + let params_none = ExecuteParams::new(".", "npm:test"); + assert!(!params_none.is_parallel()); + + let params_false = ExecuteParams::new(".", "npm:test").with_parallel(false); + assert!(!params_false.is_parallel()); + + let params_true = ExecuteParams::new(".", "npm:test").with_parallel(true); + assert!(params_true.is_parallel()); + } + + #[test] + fn test_execute_params_clone() { + let params = + ExecuteParams::new("/workspace", "npm:test").with_parallel(true).with_timeout_secs(300); + let cloned = params.clone(); + + assert_eq!(cloned.root, params.root); + assert_eq!(cloned.cmd, params.cmd); + assert_eq!(cloned.parallel, params.parallel); + assert_eq!(cloned.timeout_secs, params.timeout_secs); + } + + #[test] + fn test_execute_params_serialize() { + let params = + ExecuteParams::new("/workspace", "npm:test").with_parallel(true).with_timeout_secs(300); + let json = serde_json::to_string(¶ms).unwrap_or_default(); + + assert!(json.contains("\"root\":\"/workspace\"")); + assert!(json.contains("\"cmd\":\"npm:test\"")); + assert!(json.contains("\"parallel\":true")); + assert!(json.contains("\"timeout_secs\":300")); + // Optional fields that are None should not be present + assert!(!json.contains("\"filter_package\"")); + assert!(!json.contains("\"affected\"")); + } + + // ======================================================================== + // PackageExecutionResult Tests + // ======================================================================== + + #[test] + fn test_package_execution_result_new() { + let result = PackageExecutionResult::new("@scope/core", true, 0, 1500.0); + + assert_eq!(result.package, "@scope/core"); + assert!(result.success); + assert_eq!(result.exit_code, 0); + assert!((result.duration_ms - 1500.0).abs() < f64::EPSILON); + assert!(result.error.is_none()); + } + + #[test] + fn test_package_execution_result_success() { + let result = PackageExecutionResult::success("@scope/core", 2000.0); + + assert_eq!(result.package, "@scope/core"); + assert!(result.success); + assert_eq!(result.exit_code, 0); + assert!((result.duration_ms - 2000.0).abs() < f64::EPSILON); + assert!(result.error.is_none()); + } + + #[test] + fn test_package_execution_result_failure() { + let result = PackageExecutionResult::failure("@scope/core", 1, 500.0, "Test failed"); + + assert_eq!(result.package, "@scope/core"); + assert!(!result.success); + assert_eq!(result.exit_code, 1); + assert!((result.duration_ms - 500.0).abs() < f64::EPSILON); + assert_eq!(result.error, Some("Test failed".to_string())); + } + + #[test] + fn test_package_execution_result_with_error() { + let result = PackageExecutionResult::new("@scope/core", false, 1, 500.0) + .with_error("Command not found"); + + assert!(!result.success); + assert_eq!(result.error, Some("Command not found".to_string())); + } + + #[test] + fn test_package_execution_result_clone() { + let result = PackageExecutionResult::failure("@scope/core", 1, 500.0, "Error"); + let cloned = result.clone(); + + assert_eq!(cloned.package, result.package); + assert_eq!(cloned.success, result.success); + assert_eq!(cloned.exit_code, result.exit_code); + assert!((cloned.duration_ms - result.duration_ms).abs() < f64::EPSILON); + assert_eq!(cloned.error, result.error); + } + + #[test] + fn test_package_execution_result_serialize() { + let result = PackageExecutionResult::failure("@scope/core", 1, 500.0, "Error"); + let json = serde_json::to_string(&result).unwrap_or_default(); + + assert!(json.contains("\"package\":\"@scope/core\"")); + assert!(json.contains("\"success\":false")); + assert!(json.contains("\"exit_code\":1")); + assert!(json.contains("\"duration_ms\":500")); + assert!(json.contains("\"error\":\"Error\"")); + } + + #[test] + fn test_package_execution_result_serialize_without_error() { + let result = PackageExecutionResult::success("@scope/core", 1500.0); + let json = serde_json::to_string(&result).unwrap_or_default(); + + assert!(json.contains("\"package\":\"@scope/core\"")); + assert!(json.contains("\"success\":true")); + // error field should not be present when None + assert!(!json.contains("\"error\"")); + } + + // ======================================================================== + // ExecuteSummary Tests + // ======================================================================== + + #[test] + fn test_execute_summary_new() { + let summary = ExecuteSummary::new(5, 4, 1, 15000.0); + + assert_eq!(summary.total, 5); + assert_eq!(summary.succeeded, 4); + assert_eq!(summary.failed, 1); + assert!((summary.total_duration_ms - 15000.0).abs() < f64::EPSILON); + } + + #[test] + fn test_execute_summary_empty() { + let summary = ExecuteSummary::empty(); + + assert_eq!(summary.total, 0); + assert_eq!(summary.succeeded, 0); + assert_eq!(summary.failed, 0); + assert!((summary.total_duration_ms - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_execute_summary_from_results() { + let results = vec![ + PackageExecutionResult::success("pkg1", 1000.0), + PackageExecutionResult::success("pkg2", 500.0), + PackageExecutionResult::failure("pkg3", 1, 300.0, "Error"), + ]; + let summary = ExecuteSummary::from_results(&results); + + assert_eq!(summary.total, 3); + assert_eq!(summary.succeeded, 2); + assert_eq!(summary.failed, 1); + assert!((summary.total_duration_ms - 1800.0).abs() < f64::EPSILON); + } + + #[test] + fn test_execute_summary_from_results_empty() { + let results: Vec = vec![]; + let summary = ExecuteSummary::from_results(&results); + + assert_eq!(summary.total, 0); + assert_eq!(summary.succeeded, 0); + assert_eq!(summary.failed, 0); + assert!((summary.total_duration_ms - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_execute_summary_all_succeeded() { + let all_pass = ExecuteSummary::new(3, 3, 0, 1000.0); + assert!(all_pass.all_succeeded()); + + let some_fail = ExecuteSummary::new(3, 2, 1, 1000.0); + assert!(!some_fail.all_succeeded()); + + let empty = ExecuteSummary::empty(); + assert!(!empty.all_succeeded()); + } + + #[test] + fn test_execute_summary_has_failures() { + let all_pass = ExecuteSummary::new(3, 3, 0, 1000.0); + assert!(!all_pass.has_failures()); + + let some_fail = ExecuteSummary::new(3, 2, 1, 1000.0); + assert!(some_fail.has_failures()); + + let all_fail = ExecuteSummary::new(3, 0, 3, 1000.0); + assert!(all_fail.has_failures()); + } + + #[test] + fn test_execute_summary_clone() { + let summary = ExecuteSummary::new(5, 4, 1, 15000.0); + let cloned = summary.clone(); + + assert_eq!(cloned.total, summary.total); + assert_eq!(cloned.succeeded, summary.succeeded); + assert_eq!(cloned.failed, summary.failed); + assert!((cloned.total_duration_ms - summary.total_duration_ms).abs() < f64::EPSILON); + } + + #[test] + fn test_execute_summary_serialize() { + let summary = ExecuteSummary::new(5, 4, 1, 15000.0); + let json = serde_json::to_string(&summary).unwrap_or_default(); + + assert!(json.contains("\"total\":5")); + assert!(json.contains("\"succeeded\":4")); + assert!(json.contains("\"failed\":1")); + assert!(json.contains("\"total_duration_ms\":15000")); + } + + // ======================================================================== + // ExecuteData Tests + // ======================================================================== + + #[test] + fn test_execute_data_new() { + let results = vec![PackageExecutionResult::success("pkg1", 1000.0)]; + let summary = ExecuteSummary::new(1, 1, 0, 1000.0); + let data = ExecuteData::new("npm:test", results, summary); + + assert_eq!(data.command, "npm:test"); + assert_eq!(data.results.len(), 1); + assert_eq!(data.summary.total, 1); + } + + #[test] + fn test_execute_data_from_results() { + let results = vec![ + PackageExecutionResult::success("pkg1", 1000.0), + PackageExecutionResult::failure("pkg2", 1, 500.0, "Error"), + ]; + let data = ExecuteData::from_results("npm:build", results); + + assert_eq!(data.command, "npm:build"); + assert_eq!(data.results.len(), 2); + assert_eq!(data.summary.total, 2); + assert_eq!(data.summary.succeeded, 1); + assert_eq!(data.summary.failed, 1); + assert!((data.summary.total_duration_ms - 1500.0).abs() < f64::EPSILON); + } + + #[test] + fn test_execute_data_empty() { + let data = ExecuteData::empty("npm:lint"); + + assert_eq!(data.command, "npm:lint"); + assert!(data.results.is_empty()); + assert_eq!(data.summary.total, 0); + } + + #[test] + fn test_execute_data_package_count() { + let data = ExecuteData::from_results( + "npm:test", + vec![ + PackageExecutionResult::success("pkg1", 1000.0), + PackageExecutionResult::success("pkg2", 500.0), + ], + ); + + assert_eq!(data.package_count(), 2); + } + + #[test] + fn test_execute_data_all_succeeded() { + let all_pass = ExecuteData::from_results( + "npm:test", + vec![ + PackageExecutionResult::success("pkg1", 1000.0), + PackageExecutionResult::success("pkg2", 500.0), + ], + ); + assert!(all_pass.all_succeeded()); + + let some_fail = ExecuteData::from_results( + "npm:test", + vec![ + PackageExecutionResult::success("pkg1", 1000.0), + PackageExecutionResult::failure("pkg2", 1, 500.0, "Error"), + ], + ); + assert!(!some_fail.all_succeeded()); + } + + #[test] + fn test_execute_data_has_failures() { + let all_pass = ExecuteData::from_results( + "npm:test", + vec![PackageExecutionResult::success("pkg1", 1000.0)], + ); + assert!(!all_pass.has_failures()); + + let some_fail = ExecuteData::from_results( + "npm:test", + vec![PackageExecutionResult::failure("pkg1", 1, 500.0, "Error")], + ); + assert!(some_fail.has_failures()); + } + + #[test] + fn test_execute_data_clone() { + let data = ExecuteData::from_results( + "npm:test", + vec![PackageExecutionResult::success("pkg1", 1000.0)], + ); + let cloned = data.clone(); + + assert_eq!(cloned.command, data.command); + assert_eq!(cloned.results.len(), data.results.len()); + assert_eq!(cloned.summary.total, data.summary.total); + } + + #[test] + fn test_execute_data_serialize() { + let data = ExecuteData::from_results( + "npm:test", + vec![PackageExecutionResult::success("@scope/core", 1000.0)], + ); + let json = serde_json::to_string(&data).unwrap_or_default(); + + assert!(json.contains("\"command\":\"npm:test\"")); + assert!(json.contains("\"package\":\"@scope/core\"")); + assert!(json.contains("\"total\":1")); + assert!(json.contains("\"succeeded\":1")); + } + + // ======================================================================== + // ExecuteApiResponse Tests + // ======================================================================== + + #[test] + fn test_execute_api_response_success() { + let data = ExecuteData::empty("npm:test"); + let response = ExecuteApiResponse::success(data); + + assert!(response.success); + assert!(response.data.is_some()); + assert!(response.error.is_none()); + assert!(response.is_success()); + assert!(!response.is_failure()); + } + + #[test] + fn test_execute_api_response_failure() { + let error = ErrorInfo::validation("Invalid command", Some("cmd")); + let response = ExecuteApiResponse::failure(error); + + assert!(!response.success); + assert!(response.data.is_none()); + assert!(response.error.is_some()); + assert!(!response.is_success()); + assert!(response.is_failure()); + } + + #[test] + fn test_execute_api_response_failure_with_different_error_codes() { + // EVALIDATION + let validation_error = ErrorInfo::validation("Invalid root", Some("root")); + let validation_response = ExecuteApiResponse::failure(validation_error); + assert_eq!( + validation_response.error.as_ref().map(|e| e.code.as_str()), + Some("EVALIDATION") + ); + + // ENOENT (Entity Not Found - Unix/Node.js standard) + let not_found_error = ErrorInfo::not_found("Path not found", Some("root")); + let not_found_response = ExecuteApiResponse::failure(not_found_error); + assert_eq!(not_found_response.error.as_ref().map(|e| e.code.as_str()), Some("ENOENT")); + + // ETIMEOUT + let timeout_error = ErrorInfo::timeout("Operation timed out"); + let timeout_response = ExecuteApiResponse::failure(timeout_error); + assert_eq!(timeout_response.error.as_ref().map(|e| e.code.as_str()), Some("ETIMEOUT")); + } + + #[test] + fn test_execute_api_response_clone() { + let data = ExecuteData::empty("npm:test"); + let response = ExecuteApiResponse::success(data); + let cloned = response.clone(); + + assert_eq!(cloned.success, response.success); + assert!(cloned.data.is_some()); + } + + #[test] + fn test_execute_api_response_serialize_success() { + let data = ExecuteData::empty("npm:test"); + let response = ExecuteApiResponse::success(data); + let json = serde_json::to_string(&response).unwrap_or_default(); + + assert!(json.contains("\"success\":true")); + assert!(json.contains("\"command\":\"npm:test\"")); + assert!(!json.contains("\"error\"")); + } + + #[test] + fn test_execute_api_response_serialize_failure() { + let error = ErrorInfo::validation("Invalid command", Some("cmd")); + let response = ExecuteApiResponse::failure(error); + let json = serde_json::to_string(&response).unwrap_or_default(); + + assert!(json.contains("\"success\":false")); + assert!(json.contains("\"code\":\"EVALIDATION\"")); + assert!(!json.contains("\"data\"")); + } + + #[test] + fn test_execute_api_response_with_full_data() { + let results = vec![ + PackageExecutionResult::success("@scope/core", 1500.0), + PackageExecutionResult::failure("@scope/utils", 1, 800.0, "Test failed"), + ]; + let data = ExecuteData::from_results("npm:test", results); + let response = ExecuteApiResponse::success(data); + + assert!(response.success); + let data = response.data.as_ref().unwrap(); + assert_eq!(data.command, "npm:test"); + assert_eq!(data.results.len(), 2); + assert_eq!(data.summary.total, 2); + assert_eq!(data.summary.succeeded, 1); + assert_eq!(data.summary.failed, 1); + } + + // ======================================================================== + // Integration Tests + // ======================================================================== + + #[test] + fn test_execute_complete_scenario_parallel() { + // Simulate parallel execution on affected packages + let params = ExecuteParams::new("/workspace", "npm:test") + .with_affected(true) + .with_branch("main") + .with_parallel(true) + .with_timeout_secs(300) + .with_per_package_timeout_secs(60); + + assert!(params.is_affected()); + assert!(params.is_parallel()); + assert!(!params.has_filter_package()); + + // Simulate results + let results = vec![ + PackageExecutionResult::success("@scope/core", 2000.0), + PackageExecutionResult::success("@scope/utils", 1500.0), + PackageExecutionResult::success("@scope/cli", 3000.0), + ]; + let data = ExecuteData::from_results(¶ms.cmd, results); + let response = ExecuteApiResponse::success(data); + + assert!(response.is_success()); + let data = response.data.as_ref().unwrap(); + assert!(data.all_succeeded()); + assert!(!data.has_failures()); + assert_eq!(data.summary.total, 3); + assert_eq!(data.summary.succeeded, 3); + } + + #[test] + fn test_execute_complete_scenario_filtered() { + // Simulate execution on specific packages + let params = ExecuteParams::new("/workspace", "npm:build") + .with_filter_package(vec!["@scope/core".to_string(), "@scope/utils".to_string()]) + .with_parallel(false); + + assert!(!params.is_affected()); + assert!(!params.is_parallel()); + assert!(params.has_filter_package()); + + // Simulate results with one failure + let results = vec![ + PackageExecutionResult::success("@scope/core", 5000.0), + PackageExecutionResult::failure( + "@scope/utils", + 1, + 2000.0, + "Build failed: missing dependency", + ), + ]; + let data = ExecuteData::from_results(¶ms.cmd, results); + let response = ExecuteApiResponse::success(data); + + assert!(response.is_success()); + let data = response.data.as_ref().unwrap(); + assert!(!data.all_succeeded()); + assert!(data.has_failures()); + assert_eq!(data.summary.succeeded, 1); + assert_eq!(data.summary.failed, 1); + } + + #[test] + fn test_execute_system_command() { + // Test with a system command (not npm script) + let params = + ExecuteParams::new("/workspace", "echo hello").with_args(vec!["world".to_string()]); + + assert_eq!(params.cmd, "echo hello"); + assert_eq!(params.args, Some(vec!["world".to_string()])); + + let results = vec![PackageExecutionResult::success("root", 50.0)]; + let data = ExecuteData::from_results(¶ms.cmd, results); + + assert_eq!(data.command, "echo hello"); + assert!((data.summary.total_duration_ms - 50.0).abs() < f64::EPSILON); + } +} diff --git a/crates/node/src/types/execute.rs b/crates/node/src/types/execute.rs index f07ad633..c2bbe4ee 100644 --- a/crates/node/src/types/execute.rs +++ b/crates/node/src/types/execute.rs @@ -1,40 +1,53 @@ -//! Execute command type definitions. +//! Execute command type definitions for Node.js bindings. //! //! # What //! -//! This module contains type definitions for the execute command, including -//! parameter structures and response data types. The execute command runs +//! This module defines all NAPI-compatible type structures for the execute command, +//! including input parameters and response data types. The execute command runs //! arbitrary commands across workspace packages with filtering, parallelism, //! and timeout support. //! //! # How //! -//! Types are defined with `#[napi(object)]` attribute to be exposed as -//! JavaScript objects. The module provides: +//! Types are defined with the `#[napi(object)]` attribute to be automatically +//! exposed as JavaScript objects. The module provides: //! -//! - `ExecuteParams`: Input parameters for the execute command -//! - `ExecuteData`: Response data containing execution results +//! - **Input Parameters**: `ExecuteParams` +//! - **Response Data**: `ExecuteData`, `PackageExecutionResult`, `ExecuteSummary` +//! - **API Response**: `ExecuteApiResponse` for consistent success/error handling +//! +//! All types implement `Clone`, `Debug`, and `Serialize` for flexibility in +//! testing and serialization scenarios. //! //! # Why //! //! The execute command enables running scripts across multiple packages with //! intelligent filtering (affected packages, specific packages) and execution //! control (parallel, timeout). It's essential for CI/CD workflows and -//! development tasks. +//! development tasks. These types provide: +//! +//! - **Type safety**: Strong typing for JavaScript/TypeScript consumers +//! - **Documentation**: Self-documenting API through TypeScript definitions +//! - **Consistency**: Matches the CLI JSON output structure for compatibility +//! - **Validation**: Enables parameter validation before CLI execution +//! - **Timeout control**: Configurable timeouts at global and per-package level //! //! # Examples //! +//! ## TypeScript Usage +//! //! ```typescript //! import { execute, ExecuteParams, ExecuteData } from '@websublime/workspace-tools'; //! -//! // Run tests on affected packages +//! // Run tests on affected packages with timeout //! const params: ExecuteParams = { //! root: '.', -//! cmd: 'npm test', +//! cmd: 'npm:test', //! affected: true, //! branch: 'main', //! parallel: true, -//! timeoutSecs: 300 +//! timeoutSecs: 300, // 5 minutes total timeout +//! perPackageTimeoutSecs: 60 // 1 minute per package //! }; //! const result = await execute(params); //! @@ -42,18 +55,20 @@ //! const data: ExecuteData = result.data; //! console.log(`Command: ${data.command}`); //! console.log(`Packages: ${data.results.length}`); -//! console.log(`Summary: ${data.summary.successful}/${data.summary.total} succeeded`); +//! console.log(`Summary: ${data.summary.succeeded}/${data.summary.total} succeeded`); //! //! for (const pkg of data.results) { //! const icon = pkg.success ? '✓' : '✗'; -//! console.log(`${icon} ${pkg.packageName}: ${pkg.exitCode}`); +//! console.log(`${icon} ${pkg.package}: exit code ${pkg.exitCode}`); //! } +//! } else { +//! console.error(`Error [${result.error.code}]: ${result.error.message}`); //! } //! //! // Run build on specific packages //! const buildResult = await execute({ //! root: '.', -//! cmd: 'npm run build', +//! cmd: 'npm:build', //! filterPackage: ['@scope/core', '@scope/utils'], //! parallel: true //! }); @@ -61,50 +76,1210 @@ //! // Run lint with per-package timeout //! const lintResult = await execute({ //! root: '.', -//! cmd: 'npm run lint', +//! cmd: 'npm:lint', //! perPackageTimeoutSecs: 60 //! }); +//! +//! // Run system command across all packages +//! const systemResult = await execute({ +//! root: '.', +//! cmd: 'ls -la', +//! args: ['-h'] // Additional arguments +//! }); //! ``` +//! +//! ## Rust Usage (Internal) +//! +//! ```rust,ignore +//! use sublime_node_tools::types::execute::{ +//! ExecuteParams, ExecuteData, PackageExecutionResult, ExecuteSummary +//! }; +//! +//! // Creating params for validation +//! let params = ExecuteParams::new(".", "npm:test") +//! .with_affected(true) +//! .with_parallel(true) +//! .with_timeout_secs(300); +//! +//! // Constructing response data +//! let result = PackageExecutionResult::new("@scope/pkg", true, 0, 1500); +//! let summary = ExecuteSummary::new(1, 1, 0, 1500); +//! let data = ExecuteData::new("npm:test", vec![result], summary); +//! ``` + +use napi_derive::napi; +use serde::Serialize; + +use crate::error::ErrorInfo; + +// ============================================================================ +// Input Parameters +// ============================================================================ + +/// Input parameters for the execute command. +/// +/// This structure defines the parameters for running commands across workspace +/// packages. It supports filtering by package names or affected packages, +/// parallel execution, and configurable timeouts. +/// +/// # Fields +/// +/// - `root`: The workspace root directory path (required) +/// - `cmd`: The command to execute (required) +/// - `filter_package`: Filter by specific package names +/// - `affected`: Execute only on affected packages +/// - `since`: Git reference for affected detection start +/// - `until`: Git reference for affected detection end +/// - `branch`: Base branch for affected comparison +/// - `parallel`: Run commands in parallel +/// - `args`: Additional arguments to pass to the command +/// - `timeout_secs`: Global timeout in seconds +/// - `per_package_timeout_secs`: Per-package timeout in seconds +/// +/// # Mutual Exclusion +/// +/// `filter_package` and `affected` are mutually exclusive. Only one can be +/// specified at a time. Validation should ensure this constraint is enforced. +/// +/// # TypeScript Definition +/// +/// ```typescript +/// interface ExecuteParams { +/// root: string; +/// cmd: string; +/// filterPackage?: string[]; +/// affected?: boolean; +/// since?: string; +/// until?: string; +/// branch?: string; +/// parallel?: boolean; +/// args?: string[]; +/// timeoutSecs?: number; +/// perPackageTimeoutSecs?: number; +/// } +/// ``` +/// +/// # Examples +/// +/// ```typescript +/// // Run tests on affected packages +/// const params: ExecuteParams = { +/// root: '.', +/// cmd: 'npm:test', +/// affected: true, +/// branch: 'main', +/// parallel: true +/// }; +/// +/// // Run build on specific packages with timeout +/// const buildParams: ExecuteParams = { +/// root: '/path/to/workspace', +/// cmd: 'npm:build', +/// filterPackage: ['@scope/core', '@scope/utils'], +/// timeoutSecs: 600, +/// perPackageTimeoutSecs: 120 +/// }; +/// +/// // Run system command with extra arguments +/// const systemParams: ExecuteParams = { +/// root: '.', +/// cmd: 'echo', +/// args: ['Hello', 'World'] +/// }; +/// ``` +#[napi(object)] +#[derive(Debug, Clone, Serialize)] +pub struct ExecuteParams { + /// Workspace root directory path. + /// + /// This is the absolute or relative path to the root of the workspace. + /// For monorepos, this should point to the root where the package manager + /// configuration is located. + pub root: String, + + /// Command to execute. + /// + /// Supports two formats: + /// - `npm: