Skip to main content

ipckit/
cli_bridge.rs

1//! # CLI Bridge
2//!
3//! This module provides a CLI integration bridge that allows any CLI tool to easily
4//! integrate into the ipckit ecosystem, enabling real-time communication with frontends.
5//! Similar to how Docker CLI integrates with Docker Desktop.
6//!
7//! ## Features
8//!
9//! - Minimal invasiveness - existing CLI only needs minimal modifications
10//! - Automatic output capture (stdout/stderr)
11//! - Progress bar parsing
12//! - Bidirectional communication (CLI can send events, frontend can send commands)
13//!
14//! ## Example
15//!
16//! ```rust,ignore
17//! use ipckit::{CliBridge, WrappedCommand};
18//!
19//! // Method 1: Direct bridge usage
20//! let bridge = CliBridge::connect()?;
21//! bridge.register_task("My CLI Task", "build")?;
22//!
23//! bridge.log("info", "Starting build...");
24//!
25//! for i in 0..100 {
26//!     if bridge.is_cancelled() {
27//!         bridge.fail("Cancelled by user");
28//!         return Ok(());
29//!     }
30//!     bridge.set_progress(i + 1, Some(&format!("Step {}/100", i + 1)));
31//! }
32//!
33//! bridge.complete(json!({"success": true}));
34//!
35//! // Method 2: Wrap existing command
36//! let output = WrappedCommand::new("cargo")
37//!     .args(["build", "--release"])
38//!     .task("Build Project", "build")
39//!     .progress_parser(parsers::PercentageParser)
40//!     .run()?;
41//! ```
42
43use crate::api_server::ApiClient;
44use crate::error::{IpcError, Result};
45use crate::socket_server::SocketServerConfig;
46use crate::task_manager::CancellationToken;
47use parking_lot::RwLock;
48use serde::{Deserialize, Serialize};
49use std::io::{BufRead, BufReader, Write};
50use std::process::{Child, Command, ExitStatus, Stdio};
51use std::sync::atomic::{AtomicBool, Ordering};
52use std::sync::Arc;
53use std::thread::{self, JoinHandle};
54use std::time::{Duration, Instant};
55
56/// CLI bridge configuration.
57#[derive(Clone)]
58pub struct CliBridgeConfig {
59    /// API server address (socket path)
60    pub server_url: String,
61    /// Auto-register as task when connecting
62    pub auto_register: bool,
63    /// Capture stdout
64    pub capture_stdout: bool,
65    /// Capture stderr
66    pub capture_stderr: bool,
67    /// Progress parser (optional)
68    pub progress_parser: Option<Arc<dyn ProgressParser>>,
69    /// Connection timeout
70    pub connect_timeout: Duration,
71    /// Retry count for connection
72    pub retry_count: u32,
73    /// Retry delay
74    pub retry_delay: Duration,
75}
76
77impl std::fmt::Debug for CliBridgeConfig {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.debug_struct("CliBridgeConfig")
80            .field("server_url", &self.server_url)
81            .field("auto_register", &self.auto_register)
82            .field("capture_stdout", &self.capture_stdout)
83            .field("capture_stderr", &self.capture_stderr)
84            .field("progress_parser", &self.progress_parser.is_some())
85            .field("connect_timeout", &self.connect_timeout)
86            .field("retry_count", &self.retry_count)
87            .field("retry_delay", &self.retry_delay)
88            .finish()
89    }
90}
91
92impl Default for CliBridgeConfig {
93    fn default() -> Self {
94        Self {
95            server_url: SocketServerConfig::default().path,
96            auto_register: true,
97            capture_stdout: true,
98            capture_stderr: true,
99            progress_parser: None,
100            connect_timeout: Duration::from_secs(5),
101            retry_count: 3,
102            retry_delay: Duration::from_millis(500),
103        }
104    }
105}
106
107impl CliBridgeConfig {
108    /// Create a new configuration with the specified server URL.
109    pub fn with_server(url: &str) -> Self {
110        Self {
111            server_url: url.to_string(),
112            ..Default::default()
113        }
114    }
115
116    /// Set the progress parser.
117    pub fn progress_parser<P: ProgressParser + 'static>(mut self, parser: P) -> Self {
118        self.progress_parser = Some(Arc::new(parser));
119        self
120    }
121
122    /// Disable auto-registration.
123    pub fn no_auto_register(mut self) -> Self {
124        self.auto_register = false;
125        self
126    }
127
128    /// Load configuration from environment variables.
129    pub fn from_env() -> Self {
130        let mut config = Self::default();
131
132        if let Ok(url) = std::env::var("IPCKIT_SERVER_URL") {
133            config.server_url = url;
134        }
135
136        if let Ok(auto_reg) = std::env::var("IPCKIT_AUTO_REGISTER") {
137            config.auto_register = auto_reg.to_lowercase() != "false";
138        }
139
140        config
141    }
142}
143
144/// Progress information parsed from output.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct ProgressInfo {
147    /// Current progress value
148    pub current: u64,
149    /// Total value (for percentage calculation)
150    pub total: u64,
151    /// Optional message
152    pub message: Option<String>,
153}
154
155impl ProgressInfo {
156    /// Create a new progress info.
157    pub fn new(current: u64, total: u64) -> Self {
158        Self {
159            current,
160            total,
161            message: None,
162        }
163    }
164
165    /// Create progress info with a message.
166    pub fn with_message(current: u64, total: u64, message: &str) -> Self {
167        Self {
168            current,
169            total,
170            message: Some(message.to_string()),
171        }
172    }
173
174    /// Get the percentage (0-100).
175    pub fn percentage(&self) -> u8 {
176        (self.current * 100)
177            .checked_div(self.total)
178            .map(|p| p.min(100) as u8)
179            .unwrap_or(0)
180    }
181}
182
183/// Trait for parsing progress from output lines.
184pub trait ProgressParser: Send + Sync {
185    /// Parse progress from an output line.
186    fn parse(&self, line: &str) -> Option<ProgressInfo>;
187}
188
189/// Built-in progress parsers.
190pub mod parsers {
191    use super::*;
192    use regex::Regex;
193    use std::sync::LazyLock;
194
195    /// Percentage parser - matches patterns like "50%", "Progress: 50%", etc.
196    #[derive(Debug, Clone, Default)]
197    pub struct PercentageParser;
198
199    impl ProgressParser for PercentageParser {
200        fn parse(&self, line: &str) -> Option<ProgressInfo> {
201            static RE: LazyLock<Regex> =
202                LazyLock::new(|| Regex::new(r"(\d{1,3})%").expect("Invalid regex"));
203
204            RE.captures(line).and_then(|caps| {
205                caps.get(1)
206                    .and_then(|m| m.as_str().parse::<u64>().ok())
207                    .map(|pct| ProgressInfo::new(pct.min(100), 100))
208            })
209        }
210    }
211
212    /// Fraction parser - matches patterns like "5/10", "[5/10]", etc.
213    #[derive(Debug, Clone, Default)]
214    pub struct FractionParser;
215
216    impl ProgressParser for FractionParser {
217        fn parse(&self, line: &str) -> Option<ProgressInfo> {
218            static RE: LazyLock<Regex> =
219                LazyLock::new(|| Regex::new(r"(\d+)\s*/\s*(\d+)").expect("Invalid regex"));
220
221            RE.captures(line).and_then(|caps| {
222                let current = caps.get(1)?.as_str().parse::<u64>().ok()?;
223                let total = caps.get(2)?.as_str().parse::<u64>().ok()?;
224                Some(ProgressInfo::new(current, total))
225            })
226        }
227    }
228
229    /// Progress bar parser - matches patterns like "[=====>    ] 50%"
230    #[derive(Debug, Clone, Default)]
231    pub struct ProgressBarParser;
232
233    impl ProgressParser for ProgressBarParser {
234        fn parse(&self, line: &str) -> Option<ProgressInfo> {
235            static RE: LazyLock<Regex> = LazyLock::new(|| {
236                Regex::new(r"\[([=\-#>]+)\s*\]\s*(\d{1,3})%").expect("Invalid regex")
237            });
238
239            RE.captures(line).and_then(|caps| {
240                caps.get(2)
241                    .and_then(|m| m.as_str().parse::<u64>().ok())
242                    .map(|pct| ProgressInfo::new(pct.min(100), 100))
243            })
244        }
245    }
246
247    /// Composite parser - tries multiple parsers in order.
248    #[derive(Default)]
249    pub struct CompositeParser {
250        parsers: Vec<Arc<dyn ProgressParser>>,
251    }
252
253    impl CompositeParser {
254        /// Create a new composite parser.
255        pub fn new() -> Self {
256            Self::default()
257        }
258
259        /// Add a parser.
260        #[allow(clippy::should_implement_trait)]
261        pub fn add<P: ProgressParser + 'static>(mut self, parser: P) -> Self {
262            self.parsers.push(Arc::new(parser));
263            self
264        }
265
266        /// Create a default composite parser with all built-in parsers.
267        pub fn default_all() -> Self {
268            Self::new()
269                .add(PercentageParser)
270                .add(FractionParser)
271                .add(ProgressBarParser)
272        }
273    }
274
275    impl ProgressParser for CompositeParser {
276        fn parse(&self, line: &str) -> Option<ProgressInfo> {
277            for parser in &self.parsers {
278                if let Some(info) = parser.parse(line) {
279                    return Some(info);
280                }
281            }
282            None
283        }
284    }
285}
286
287/// Internal state for the CLI bridge.
288struct BridgeState {
289    task_id: Option<String>,
290    task_name: Option<String>,
291    task_type: Option<String>,
292    progress: u8,
293    progress_message: Option<String>,
294    cancelled: AtomicBool,
295    completed: AtomicBool,
296}
297
298impl Default for BridgeState {
299    fn default() -> Self {
300        Self {
301            task_id: None,
302            task_name: None,
303            task_type: None,
304            progress: 0,
305            progress_message: None,
306            cancelled: AtomicBool::new(false),
307            completed: AtomicBool::new(false),
308        }
309    }
310}
311
312/// CLI Bridge for integrating CLI tools with ipckit.
313pub struct CliBridge {
314    config: CliBridgeConfig,
315    client: Option<ApiClient>,
316    state: Arc<RwLock<BridgeState>>,
317    cancel_token: CancellationToken,
318}
319
320impl CliBridge {
321    /// Create a new CLI bridge with the given configuration.
322    pub fn new(config: CliBridgeConfig) -> Result<Self> {
323        Ok(Self {
324            config,
325            client: None,
326            state: Arc::new(RwLock::new(BridgeState::default())),
327            cancel_token: CancellationToken::new(),
328        })
329    }
330
331    /// Connect with default configuration.
332    pub fn connect() -> Result<Self> {
333        Self::connect_with_config(CliBridgeConfig::from_env())
334    }
335
336    /// Connect with the given configuration.
337    pub fn connect_with_config(config: CliBridgeConfig) -> Result<Self> {
338        let client = ApiClient::new(&config.server_url);
339
340        Ok(Self {
341            config,
342            client: Some(client),
343            state: Arc::new(RwLock::new(BridgeState::default())),
344            cancel_token: CancellationToken::new(),
345        })
346    }
347
348    /// Register the current process as a task.
349    pub fn register_task(&self, name: &str, task_type: &str) -> Result<String> {
350        let task_id = format!(
351            "cli-{}-{}",
352            std::process::id(),
353            std::time::SystemTime::now()
354                .duration_since(std::time::UNIX_EPOCH)
355                .unwrap_or_default()
356                .as_millis()
357        );
358
359        {
360            let mut state = self.state.write();
361            state.task_id = Some(task_id.clone());
362            state.task_name = Some(name.to_string());
363            state.task_type = Some(task_type.to_string());
364        }
365
366        // If connected, register with the server
367        if let Some(ref client) = self.client {
368            let _ = client.post(
369                "/v1/tasks",
370                Some(serde_json::json!({
371                    "id": task_id,
372                    "name": name,
373                    "type": task_type,
374                    "status": "running"
375                })),
376            );
377        }
378
379        Ok(task_id)
380    }
381
382    /// Get the current task ID.
383    pub fn task_id(&self) -> Option<String> {
384        self.state.read().task_id.clone()
385    }
386
387    /// Set the progress.
388    pub fn set_progress(&self, progress: u8, message: Option<&str>) {
389        let progress = progress.min(100);
390
391        {
392            let mut state = self.state.write();
393            state.progress = progress;
394            if let Some(msg) = message {
395                state.progress_message = Some(msg.to_string());
396            }
397        }
398
399        // Send to server if connected
400        if let (Some(ref client), Some(task_id)) = (&self.client, self.task_id()) {
401            let _ = client.post(
402                &format!("/v1/tasks/{}/progress", task_id),
403                Some(serde_json::json!({
404                    "progress": progress,
405                    "message": message
406                })),
407            );
408        }
409    }
410
411    /// Log a message.
412    pub fn log(&self, level: &str, message: &str) {
413        // Print to stderr for CLI visibility
414        eprintln!("[{}] {}", level.to_uppercase(), message);
415
416        // Send to server if connected
417        if let (Some(ref client), Some(task_id)) = (&self.client, self.task_id()) {
418            let _ = client.post(
419                &format!("/v1/tasks/{}/logs", task_id),
420                Some(serde_json::json!({
421                    "level": level,
422                    "message": message
423                })),
424            );
425        }
426    }
427
428    /// Send stdout line.
429    pub fn stdout(&self, line: &str) {
430        println!("{}", line);
431
432        if let (Some(ref client), Some(task_id)) = (&self.client, self.task_id()) {
433            let _ = client.post(
434                &format!("/v1/tasks/{}/stdout", task_id),
435                Some(serde_json::json!({ "line": line })),
436            );
437        }
438    }
439
440    /// Send stderr line.
441    pub fn stderr(&self, line: &str) {
442        eprintln!("{}", line);
443
444        if let (Some(ref client), Some(task_id)) = (&self.client, self.task_id()) {
445            let _ = client.post(
446                &format!("/v1/tasks/{}/stderr", task_id),
447                Some(serde_json::json!({ "line": line })),
448            );
449        }
450    }
451
452    /// Check if cancellation has been requested.
453    pub fn is_cancelled(&self) -> bool {
454        self.cancel_token.is_cancelled() || self.state.read().cancelled.load(Ordering::SeqCst)
455    }
456
457    /// Get the cancellation token.
458    pub fn cancel_token(&self) -> CancellationToken {
459        self.cancel_token.clone()
460    }
461
462    /// Mark the task as complete.
463    pub fn complete(&self, result: serde_json::Value) {
464        self.state.write().completed.store(true, Ordering::SeqCst);
465
466        if let (Some(ref client), Some(task_id)) = (&self.client, self.task_id()) {
467            let _ = client.post(
468                &format!("/v1/tasks/{}/complete", task_id),
469                Some(serde_json::json!({ "result": result })),
470            );
471        }
472    }
473
474    /// Mark the task as failed.
475    pub fn fail(&self, error: &str) {
476        self.state.write().completed.store(true, Ordering::SeqCst);
477
478        if let (Some(ref client), Some(task_id)) = (&self.client, self.task_id()) {
479            let _ = client.post(
480                &format!("/v1/tasks/{}/fail", task_id),
481                Some(serde_json::json!({ "error": error })),
482            );
483        }
484    }
485
486    /// Create a stdout wrapper that auto-forwards output.
487    pub fn wrap_stdout(&self) -> WrappedWriter {
488        WrappedWriter::new(
489            self.config.server_url.clone(),
490            self.task_id(),
491            OutputType::Stdout,
492            self.config.progress_parser.clone(),
493            Arc::clone(&self.state),
494        )
495    }
496
497    /// Create a stderr wrapper that auto-forwards output.
498    pub fn wrap_stderr(&self) -> WrappedWriter {
499        WrappedWriter::new(
500            self.config.server_url.clone(),
501            self.task_id(),
502            OutputType::Stderr,
503            None,
504            Arc::clone(&self.state),
505        )
506    }
507}
508
509/// Output type for wrapped writers.
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
511pub enum OutputType {
512    Stdout,
513    Stderr,
514}
515
516/// A writer that wraps stdout/stderr and forwards to the server.
517pub struct WrappedWriter {
518    client: Option<ApiClient>,
519    task_id: Option<String>,
520    output_type: OutputType,
521    progress_parser: Option<Arc<dyn ProgressParser>>,
522    state: Arc<RwLock<BridgeState>>,
523    buffer: Vec<u8>,
524}
525
526impl WrappedWriter {
527    fn new(
528        server_url: String,
529        task_id: Option<String>,
530        output_type: OutputType,
531        progress_parser: Option<Arc<dyn ProgressParser>>,
532        state: Arc<RwLock<BridgeState>>,
533    ) -> Self {
534        let client = Some(ApiClient::new(&server_url));
535        Self {
536            client,
537            task_id,
538            output_type,
539            progress_parser,
540            state,
541            buffer: Vec::new(),
542        }
543    }
544
545    fn process_line(&mut self, line: &str) {
546        // Check for progress
547        if let Some(ref parser) = self.progress_parser {
548            if let Some(info) = parser.parse(line) {
549                let mut state = self.state.write();
550                state.progress = info.percentage();
551                state.progress_message = info.message.clone();
552            }
553        }
554
555        // Send to server
556        if let (Some(ref client), Some(ref task_id)) = (&self.client, &self.task_id) {
557            let endpoint = match self.output_type {
558                OutputType::Stdout => format!("/v1/tasks/{}/stdout", task_id),
559                OutputType::Stderr => format!("/v1/tasks/{}/stderr", task_id),
560            };
561            let _ = client.post(&endpoint, Some(serde_json::json!({ "line": line })));
562        }
563    }
564}
565
566impl Write for WrappedWriter {
567    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
568        // Also write to actual stdout/stderr
569        let written = match self.output_type {
570            OutputType::Stdout => std::io::stdout().write(buf)?,
571            OutputType::Stderr => std::io::stderr().write(buf)?,
572        };
573
574        // Buffer and process lines
575        self.buffer.extend_from_slice(&buf[..written]);
576
577        // Process complete lines
578        while let Some(pos) = self.buffer.iter().position(|&b| b == b'\n') {
579            let line = String::from_utf8_lossy(&self.buffer[..pos]).to_string();
580            self.buffer.drain(..=pos);
581            self.process_line(&line);
582        }
583
584        Ok(written)
585    }
586
587    fn flush(&mut self) -> std::io::Result<()> {
588        // Process any remaining buffer
589        if !self.buffer.is_empty() {
590            let line = String::from_utf8_lossy(&self.buffer).to_string();
591            self.buffer.clear();
592            self.process_line(&line);
593        }
594
595        match self.output_type {
596            OutputType::Stdout => std::io::stdout().flush(),
597            OutputType::Stderr => std::io::stderr().flush(),
598        }
599    }
600}
601
602/// Output from a wrapped command.
603#[derive(Debug)]
604pub struct CommandOutput {
605    /// Exit code
606    pub exit_code: i32,
607    /// Captured stdout
608    pub stdout: String,
609    /// Captured stderr
610    pub stderr: String,
611    /// Duration of execution
612    pub duration: Duration,
613}
614
615/// A wrapped command that integrates with the CLI bridge.
616pub struct WrappedCommand {
617    command: Command,
618    task_name: String,
619    task_type: String,
620    progress_parser: Option<Arc<dyn ProgressParser>>,
621    bridge_config: CliBridgeConfig,
622}
623
624impl WrappedCommand {
625    /// Create a new wrapped command.
626    pub fn new(program: &str) -> Self {
627        let mut command = Command::new(program);
628        command.stdout(Stdio::piped()).stderr(Stdio::piped());
629
630        Self {
631            command,
632            task_name: program.to_string(),
633            task_type: "command".to_string(),
634            progress_parser: None,
635            bridge_config: CliBridgeConfig::from_env(),
636        }
637    }
638
639    /// Set the task info.
640    pub fn task(mut self, name: &str, task_type: &str) -> Self {
641        self.task_name = name.to_string();
642        self.task_type = task_type.to_string();
643        self
644    }
645
646    /// Add an argument.
647    pub fn arg(mut self, arg: &str) -> Self {
648        self.command.arg(arg);
649        self
650    }
651
652    /// Add multiple arguments.
653    pub fn args<I, S>(mut self, args: I) -> Self
654    where
655        I: IntoIterator<Item = S>,
656        S: AsRef<std::ffi::OsStr>,
657    {
658        self.command.args(args);
659        self
660    }
661
662    /// Set the working directory.
663    pub fn current_dir(mut self, dir: &std::path::Path) -> Self {
664        self.command.current_dir(dir);
665        self
666    }
667
668    /// Set an environment variable.
669    pub fn env(mut self, key: &str, value: &str) -> Self {
670        self.command.env(key, value);
671        self
672    }
673
674    /// Set the progress parser.
675    pub fn progress_parser<P: ProgressParser + 'static>(mut self, parser: P) -> Self {
676        self.progress_parser = Some(Arc::new(parser));
677        self
678    }
679
680    /// Set the bridge configuration.
681    pub fn bridge_config(mut self, config: CliBridgeConfig) -> Self {
682        self.bridge_config = config;
683        self
684    }
685
686    /// Execute the command (blocking).
687    pub fn run(mut self) -> Result<CommandOutput> {
688        let start = Instant::now();
689
690        // Try to connect to bridge
691        let bridge = CliBridge::connect_with_config(self.bridge_config.clone()).ok();
692
693        // Register task if connected
694        if let Some(ref bridge) = bridge {
695            let _ = bridge.register_task(&self.task_name, &self.task_type);
696        }
697
698        // Spawn the command
699        let mut child = self.command.spawn().map_err(IpcError::Io)?;
700
701        // Capture output
702        let stdout = child.stdout.take();
703        let stderr = child.stderr.take();
704
705        let progress_parser = self.progress_parser.clone();
706        let bridge_clone = bridge.as_ref().map(|b| b.state.clone());
707
708        // Spawn stdout reader
709        let stdout_handle: Option<JoinHandle<String>> = stdout.map(|out| {
710            let parser = progress_parser.clone();
711            let state = bridge_clone.clone();
712            thread::spawn(move || {
713                let mut output = String::new();
714                let reader = BufReader::new(out);
715                for line_result in reader.lines() {
716                    let Ok(line) = line_result else { break };
717                    println!("{}", line);
718                    output.push_str(&line);
719                    output.push('\n');
720
721                    // Parse progress
722                    if let (Some(ref parser), Some(ref state)) = (&parser, &state) {
723                        if let Some(info) = parser.parse(&line) {
724                            let mut s = state.write();
725                            s.progress = info.percentage();
726                            s.progress_message = info.message;
727                        }
728                    }
729                }
730                output
731            })
732        });
733
734        // Spawn stderr reader
735        let stderr_handle: Option<JoinHandle<String>> = stderr.map(|err| {
736            thread::spawn(move || {
737                let mut output = String::new();
738                let reader = BufReader::new(err);
739                for line_result in reader.lines() {
740                    let Ok(line) = line_result else { break };
741                    eprintln!("{}", line);
742                    output.push_str(&line);
743                    output.push('\n');
744                }
745                output
746            })
747        });
748
749        // Wait for command to complete
750        let status = child.wait().map_err(IpcError::Io)?;
751
752        // Collect output
753        let stdout_output = stdout_handle
754            .map(|h| h.join().unwrap_or_default())
755            .unwrap_or_default();
756        let stderr_output = stderr_handle
757            .map(|h| h.join().unwrap_or_default())
758            .unwrap_or_default();
759
760        let duration = start.elapsed();
761        let exit_code = status.code().unwrap_or(-1);
762
763        // Report completion
764        if let Some(ref bridge) = bridge {
765            if exit_code == 0 {
766                bridge.complete(serde_json::json!({
767                    "exit_code": exit_code,
768                    "duration_ms": duration.as_millis()
769                }));
770            } else {
771                bridge.fail(&format!("Command exited with code {}", exit_code));
772            }
773        }
774
775        Ok(CommandOutput {
776            exit_code,
777            stdout: stdout_output,
778            stderr: stderr_output,
779            duration,
780        })
781    }
782
783    /// Execute the command (non-blocking).
784    pub fn spawn(mut self) -> Result<WrappedChild> {
785        // Try to connect to bridge
786        let bridge = CliBridge::connect_with_config(self.bridge_config.clone()).ok();
787
788        // Register task if connected
789        let task_id = if let Some(ref bridge) = bridge {
790            bridge.register_task(&self.task_name, &self.task_type).ok()
791        } else {
792            None
793        };
794
795        // Spawn the command
796        let child = self.command.spawn().map_err(IpcError::Io)?;
797
798        Ok(WrappedChild {
799            child,
800            bridge,
801            task_id,
802            start_time: Instant::now(),
803        })
804    }
805}
806
807/// A wrapped child process.
808pub struct WrappedChild {
809    child: Child,
810    bridge: Option<CliBridge>,
811    task_id: Option<String>,
812    start_time: Instant,
813}
814
815impl WrappedChild {
816    /// Wait for the process to complete.
817    pub fn wait(mut self) -> Result<CommandOutput> {
818        let status = self.child.wait().map_err(IpcError::Io)?;
819        let duration = self.start_time.elapsed();
820        let exit_code = status.code().unwrap_or(-1);
821
822        // Report completion
823        if let Some(ref bridge) = self.bridge {
824            if exit_code == 0 {
825                bridge.complete(serde_json::json!({
826                    "exit_code": exit_code,
827                    "duration_ms": duration.as_millis()
828                }));
829            } else {
830                bridge.fail(&format!("Command exited with code {}", exit_code));
831            }
832        }
833
834        Ok(CommandOutput {
835            exit_code,
836            stdout: String::new(), // Not captured in spawn mode
837            stderr: String::new(),
838            duration,
839        })
840    }
841
842    /// Send a cancel signal to the process.
843    pub fn cancel(&mut self) -> Result<()> {
844        self.child.kill().map_err(IpcError::Io)
845    }
846
847    /// Get the task ID.
848    pub fn task_id(&self) -> Option<&str> {
849        self.task_id.as_deref()
850    }
851
852    /// Check if the process has exited.
853    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
854        self.child.try_wait().map_err(IpcError::Io)
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    // ==================== ProgressParser Tests ====================
863
864    #[test]
865    fn test_percentage_parser() {
866        let parser = parsers::PercentageParser;
867
868        assert_eq!(parser.parse("50%").map(|p| p.percentage()), Some(50));
869        assert_eq!(
870            parser.parse("Progress: 75%").map(|p| p.percentage()),
871            Some(75)
872        );
873        assert_eq!(
874            parser.parse("Downloading... 100%").map(|p| p.percentage()),
875            Some(100)
876        );
877        assert_eq!(
878            parser.parse("No progress here").map(|p| p.percentage()),
879            None
880        );
881    }
882
883    #[test]
884    fn test_percentage_parser_edge_cases() {
885        let parser = parsers::PercentageParser;
886
887        // Edge cases
888        assert_eq!(parser.parse("0%").map(|p| p.percentage()), Some(0));
889        assert_eq!(parser.parse("1%").map(|p| p.percentage()), Some(1));
890        assert_eq!(parser.parse("99%").map(|p| p.percentage()), Some(99));
891
892        // Values over 100 should be capped
893        assert_eq!(parser.parse("150%").map(|p| p.percentage()), Some(100));
894
895        // Multiple percentages - should match first
896        let info = parser.parse("Step 1: 25% complete, overall: 50%");
897        assert_eq!(info.map(|p| p.percentage()), Some(25));
898    }
899
900    #[test]
901    fn test_fraction_parser() {
902        let parser = parsers::FractionParser;
903
904        let info = parser.parse("5/10").unwrap();
905        assert_eq!(info.current, 5);
906        assert_eq!(info.total, 10);
907        assert_eq!(info.percentage(), 50);
908
909        let info = parser.parse("[3/4] Installing...").unwrap();
910        assert_eq!(info.current, 3);
911        assert_eq!(info.total, 4);
912        assert_eq!(info.percentage(), 75);
913
914        assert!(parser.parse("No fraction").is_none());
915    }
916
917    #[test]
918    fn test_fraction_parser_edge_cases() {
919        let parser = parsers::FractionParser;
920
921        // Zero cases
922        let info = parser.parse("0/10").unwrap();
923        assert_eq!(info.percentage(), 0);
924
925        let info = parser.parse("10/10").unwrap();
926        assert_eq!(info.percentage(), 100);
927
928        // Division by zero protection
929        let info = parser.parse("5/0").unwrap();
930        assert_eq!(info.percentage(), 0);
931
932        // Spaces around slash
933        let info = parser.parse("3 / 5").unwrap();
934        assert_eq!(info.current, 3);
935        assert_eq!(info.total, 5);
936
937        // Large numbers
938        let info = parser.parse("999/1000").unwrap();
939        assert_eq!(info.percentage(), 99);
940    }
941
942    #[test]
943    fn test_progress_bar_parser() {
944        let parser = parsers::ProgressBarParser;
945
946        assert_eq!(
947            parser.parse("[=====>    ] 50%").map(|p| p.percentage()),
948            Some(50)
949        );
950        assert_eq!(
951            parser.parse("[##########] 100%").map(|p| p.percentage()),
952            Some(100)
953        );
954    }
955
956    #[test]
957    fn test_progress_bar_parser_variants() {
958        let parser = parsers::ProgressBarParser;
959
960        // Different bar characters
961        assert_eq!(
962            parser.parse("[----------] 0%").map(|p| p.percentage()),
963            Some(0)
964        );
965        assert_eq!(
966            parser.parse("[###-------] 30%").map(|p| p.percentage()),
967            Some(30)
968        );
969        assert_eq!(
970            parser.parse("[>         ] 10%").map(|p| p.percentage()),
971            Some(10)
972        );
973    }
974
975    #[test]
976    fn test_composite_parser() {
977        let parser = parsers::CompositeParser::default_all();
978
979        assert_eq!(parser.parse("50%").map(|p| p.percentage()), Some(50));
980        assert_eq!(parser.parse("5/10").map(|p| p.percentage()), Some(50));
981        assert_eq!(
982            parser.parse("[=====>    ] 50%").map(|p| p.percentage()),
983            Some(50)
984        );
985    }
986
987    #[test]
988    fn test_composite_parser_priority() {
989        let parser = parsers::CompositeParser::default_all();
990
991        // Percentage parser has priority
992        let info = parser.parse("Step 3/5: 60% complete");
993        assert_eq!(info.map(|p| p.percentage()), Some(60));
994
995        // When no percentage, fraction is used
996        let info = parser.parse("Processing file 3/5");
997        assert_eq!(info.map(|p| p.percentage()), Some(60));
998    }
999
1000    #[test]
1001    fn test_composite_parser_no_match() {
1002        let parser = parsers::CompositeParser::default_all();
1003        assert!(parser.parse("Just some text").is_none());
1004        assert!(parser.parse("").is_none());
1005    }
1006
1007    #[test]
1008    fn test_custom_composite_parser() {
1009        let parser = parsers::CompositeParser::new()
1010            .add(parsers::FractionParser)
1011            .add(parsers::PercentageParser);
1012
1013        // Fraction has priority now
1014        let info = parser.parse("Step 3/5: 60% complete");
1015        assert_eq!(info.map(|p| p.percentage()), Some(60)); // 3/5 = 60%
1016    }
1017
1018    // ==================== ProgressInfo Tests ====================
1019
1020    #[test]
1021    fn test_progress_info() {
1022        let info = ProgressInfo::new(50, 100);
1023        assert_eq!(info.percentage(), 50);
1024
1025        let info = ProgressInfo::new(0, 0);
1026        assert_eq!(info.percentage(), 0);
1027
1028        let info = ProgressInfo::with_message(75, 100, "Almost done");
1029        assert_eq!(info.percentage(), 75);
1030        assert_eq!(info.message, Some("Almost done".to_string()));
1031    }
1032
1033    #[test]
1034    fn test_progress_info_edge_cases() {
1035        // Zero total
1036        let info = ProgressInfo::new(50, 0);
1037        assert_eq!(info.percentage(), 0);
1038
1039        // Current > Total
1040        let info = ProgressInfo::new(150, 100);
1041        assert_eq!(info.percentage(), 100);
1042
1043        // Large numbers
1044        let info = ProgressInfo::new(500000, 1000000);
1045        assert_eq!(info.percentage(), 50);
1046    }
1047
1048    #[test]
1049    fn test_progress_info_serialization() {
1050        let info = ProgressInfo::with_message(50, 100, "Halfway");
1051        let json = serde_json::to_string(&info).unwrap();
1052        assert!(json.contains("50"));
1053        assert!(json.contains("100"));
1054        assert!(json.contains("Halfway"));
1055
1056        let deserialized: ProgressInfo = serde_json::from_str(&json).unwrap();
1057        assert_eq!(deserialized.current, 50);
1058        assert_eq!(deserialized.total, 100);
1059        assert_eq!(deserialized.message, Some("Halfway".to_string()));
1060    }
1061
1062    // ==================== CliBridgeConfig Tests ====================
1063
1064    #[test]
1065    fn test_cli_bridge_config() {
1066        let config = CliBridgeConfig::default();
1067        assert!(config.auto_register);
1068        assert!(config.capture_stdout);
1069        assert!(config.capture_stderr);
1070
1071        let config = CliBridgeConfig::with_server("/tmp/test.sock");
1072        assert_eq!(config.server_url, "/tmp/test.sock");
1073
1074        let config = CliBridgeConfig::default().no_auto_register();
1075        assert!(!config.auto_register);
1076    }
1077
1078    #[test]
1079    fn test_cli_bridge_config_builder() {
1080        let config = CliBridgeConfig::default()
1081            .no_auto_register()
1082            .progress_parser(parsers::PercentageParser);
1083
1084        assert!(!config.auto_register);
1085        assert!(config.progress_parser.is_some());
1086    }
1087
1088    #[test]
1089    fn test_cli_bridge_config_debug() {
1090        let config = CliBridgeConfig::default();
1091        let debug_str = format!("{:?}", config);
1092        assert!(debug_str.contains("CliBridgeConfig"));
1093        assert!(debug_str.contains("auto_register"));
1094    }
1095
1096    // ==================== CliBridge Tests ====================
1097
1098    #[test]
1099    fn test_cli_bridge_creation() {
1100        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1101        assert!(bridge.task_id().is_none());
1102        assert!(!bridge.is_cancelled());
1103    }
1104
1105    #[test]
1106    fn test_cli_bridge_register_task() {
1107        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1108        let task_id = bridge.register_task("Test Task", "test").unwrap();
1109
1110        assert!(task_id.starts_with("cli-"));
1111        assert_eq!(bridge.task_id(), Some(task_id));
1112    }
1113
1114    #[test]
1115    fn test_cli_bridge_progress() {
1116        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1117        bridge.register_task("Test", "test").unwrap();
1118
1119        bridge.set_progress(50, Some("Halfway"));
1120        // Progress is stored internally
1121        let state = bridge.state.read();
1122        assert_eq!(state.progress, 50);
1123        assert_eq!(state.progress_message, Some("Halfway".to_string()));
1124    }
1125
1126    #[test]
1127    fn test_cli_bridge_progress_clamping() {
1128        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1129        bridge.register_task("Test", "test").unwrap();
1130
1131        // Progress should be clamped to 100
1132        bridge.set_progress(150, None);
1133        let state = bridge.state.read();
1134        assert_eq!(state.progress, 100);
1135    }
1136
1137    #[test]
1138    fn test_cli_bridge_cancellation() {
1139        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1140        assert!(!bridge.is_cancelled());
1141
1142        // Get cancel token and cancel
1143        let token = bridge.cancel_token();
1144        token.cancel();
1145        assert!(bridge.is_cancelled());
1146    }
1147
1148    #[test]
1149    fn test_cli_bridge_complete() {
1150        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1151        bridge.register_task("Test", "test").unwrap();
1152
1153        bridge.complete(serde_json::json!({"success": true}));
1154
1155        let state = bridge.state.read();
1156        assert!(state.completed.load(std::sync::atomic::Ordering::SeqCst));
1157    }
1158
1159    #[test]
1160    fn test_cli_bridge_fail() {
1161        let bridge = CliBridge::new(CliBridgeConfig::default()).unwrap();
1162        bridge.register_task("Test", "test").unwrap();
1163
1164        bridge.fail("Something went wrong");
1165
1166        let state = bridge.state.read();
1167        assert!(state.completed.load(std::sync::atomic::Ordering::SeqCst));
1168    }
1169
1170    // ==================== WrappedCommand Tests ====================
1171
1172    #[test]
1173    fn test_wrapped_command_creation() {
1174        let cmd = WrappedCommand::new("echo")
1175            .arg("hello")
1176            .task("Echo Test", "test");
1177
1178        assert_eq!(cmd.task_name, "Echo Test");
1179        assert_eq!(cmd.task_type, "test");
1180    }
1181
1182    #[test]
1183    fn test_wrapped_command_builder() {
1184        let cmd = WrappedCommand::new("cargo")
1185            .args(["build", "--release"])
1186            .task("Build", "build")
1187            .progress_parser(parsers::PercentageParser);
1188
1189        assert_eq!(cmd.task_name, "Build");
1190        assert_eq!(cmd.task_type, "build");
1191        assert!(cmd.progress_parser.is_some());
1192    }
1193
1194    #[test]
1195    fn test_wrapped_command_env() {
1196        let cmd = WrappedCommand::new("echo")
1197            .env("MY_VAR", "my_value")
1198            .env("ANOTHER_VAR", "another_value");
1199
1200        // Just verify it builds without error
1201        assert_eq!(cmd.task_type, "command");
1202    }
1203
1204    #[test]
1205    fn test_wrapped_command_current_dir() {
1206        let cmd = WrappedCommand::new("echo").current_dir(std::path::Path::new("/tmp"));
1207
1208        assert_eq!(cmd.task_type, "command");
1209    }
1210
1211    #[cfg(windows)]
1212    #[test]
1213    fn test_wrapped_command_run_echo() {
1214        let output = WrappedCommand::new("cmd")
1215            .args(["/C", "echo", "hello"])
1216            .task("Echo Test", "test")
1217            .run()
1218            .unwrap();
1219
1220        assert_eq!(output.exit_code, 0);
1221        assert!(output.stdout.contains("hello"));
1222    }
1223
1224    #[cfg(not(windows))]
1225    #[test]
1226    fn test_wrapped_command_run_echo() {
1227        let output = WrappedCommand::new("echo")
1228            .arg("hello")
1229            .task("Echo Test", "test")
1230            .run()
1231            .unwrap();
1232
1233        assert_eq!(output.exit_code, 0);
1234        assert!(output.stdout.contains("hello"));
1235    }
1236
1237    #[cfg(windows)]
1238    #[test]
1239    fn test_wrapped_command_run_failure() {
1240        let output = WrappedCommand::new("cmd")
1241            .args(["/C", "exit", "1"])
1242            .task("Fail Test", "test")
1243            .run()
1244            .unwrap();
1245
1246        assert_eq!(output.exit_code, 1);
1247    }
1248
1249    #[cfg(not(windows))]
1250    #[test]
1251    fn test_wrapped_command_run_failure() {
1252        let output = WrappedCommand::new("sh")
1253            .args(["-c", "exit 1"])
1254            .task("Fail Test", "test")
1255            .run()
1256            .unwrap();
1257
1258        assert_eq!(output.exit_code, 1);
1259    }
1260
1261    // ==================== CommandOutput Tests ====================
1262
1263    #[test]
1264    fn test_command_output_debug() {
1265        let output = CommandOutput {
1266            exit_code: 0,
1267            stdout: "hello".to_string(),
1268            stderr: String::new(),
1269            duration: Duration::from_millis(100),
1270        };
1271
1272        let debug_str = format!("{:?}", output);
1273        assert!(debug_str.contains("exit_code"));
1274        assert!(debug_str.contains("0"));
1275    }
1276
1277    // ==================== WrappedWriter Tests ====================
1278
1279    #[test]
1280    fn test_wrapped_writer_stdout() {
1281        let state = Arc::new(RwLock::new(BridgeState::default()));
1282        let mut writer = WrappedWriter::new(
1283            "/tmp/test.sock".to_string(),
1284            Some("test-task".to_string()),
1285            OutputType::Stdout,
1286            Some(Arc::new(parsers::PercentageParser)),
1287            Arc::clone(&state),
1288        );
1289
1290        // Write a line with progress
1291        let data = b"Progress: 50%\n";
1292        let written = writer.write(data).unwrap();
1293        assert_eq!(written, data.len());
1294
1295        // Check progress was parsed
1296        let s = state.read();
1297        assert_eq!(s.progress, 50);
1298    }
1299
1300    #[test]
1301    fn test_wrapped_writer_stderr() {
1302        let state = Arc::new(RwLock::new(BridgeState::default()));
1303        let mut writer = WrappedWriter::new(
1304            "/tmp/test.sock".to_string(),
1305            Some("test-task".to_string()),
1306            OutputType::Stderr,
1307            None,
1308            Arc::clone(&state),
1309        );
1310
1311        let data = b"Error message\n";
1312        let written = writer.write(data).unwrap();
1313        assert_eq!(written, data.len());
1314    }
1315
1316    #[test]
1317    fn test_wrapped_writer_buffering() {
1318        let state = Arc::new(RwLock::new(BridgeState::default()));
1319        let mut writer = WrappedWriter::new(
1320            "/tmp/test.sock".to_string(),
1321            Some("test-task".to_string()),
1322            OutputType::Stdout,
1323            Some(Arc::new(parsers::PercentageParser)),
1324            Arc::clone(&state),
1325        );
1326
1327        // Write partial line
1328        writer.write_all(b"Progress: ").unwrap();
1329        assert_eq!(state.read().progress, 0);
1330
1331        // Complete the line
1332        writer.write_all(b"75%\n").unwrap();
1333        assert_eq!(state.read().progress, 75);
1334    }
1335
1336    #[test]
1337    fn test_wrapped_writer_flush() {
1338        let state = Arc::new(RwLock::new(BridgeState::default()));
1339        let mut writer = WrappedWriter::new(
1340            "/tmp/test.sock".to_string(),
1341            Some("test-task".to_string()),
1342            OutputType::Stdout,
1343            Some(Arc::new(parsers::PercentageParser)),
1344            Arc::clone(&state),
1345        );
1346
1347        // Write without newline
1348        writer.write_all(b"Progress: 90%").unwrap();
1349        assert_eq!(state.read().progress, 0);
1350
1351        // Flush should process remaining buffer
1352        writer.flush().unwrap();
1353        assert_eq!(state.read().progress, 90);
1354    }
1355
1356    // ==================== OutputType Tests ====================
1357
1358    #[test]
1359    fn test_output_type_equality() {
1360        assert_eq!(OutputType::Stdout, OutputType::Stdout);
1361        assert_eq!(OutputType::Stderr, OutputType::Stderr);
1362        assert_ne!(OutputType::Stdout, OutputType::Stderr);
1363    }
1364
1365    #[test]
1366    fn test_output_type_debug() {
1367        assert_eq!(format!("{:?}", OutputType::Stdout), "Stdout");
1368        assert_eq!(format!("{:?}", OutputType::Stderr), "Stderr");
1369    }
1370}