1use std::time::{SystemTime, UNIX_EPOCH};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum CommandStatus {
8 Pending,
10 Running,
12 Success,
14 Failed,
16 Cancelled,
18}
19
20impl CommandStatus {
21 pub fn display_text(&self) -> &'static str {
23 match self {
24 CommandStatus::Pending => "⏳ Pending",
25 CommandStatus::Running => "⚙️ Running",
26 CommandStatus::Success => "✓ Success",
27 CommandStatus::Failed => "✗ Failed",
28 CommandStatus::Cancelled => "⊘ Cancelled",
29 }
30 }
31
32 pub fn short_text(&self) -> &'static str {
34 match self {
35 CommandStatus::Pending => "pending",
36 CommandStatus::Running => "running",
37 CommandStatus::Success => "success",
38 CommandStatus::Failed => "failed",
39 CommandStatus::Cancelled => "cancelled",
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct Command {
47 pub text: String,
49 pub status: CommandStatus,
51 pub output: String,
53 pub exit_code: Option<i32>,
55 pub created_at: u64,
57 pub started_at: Option<u64>,
59 pub finished_at: Option<u64>,
61}
62
63impl Command {
64 pub fn new(text: impl Into<String>) -> Self {
66 let now = SystemTime::now()
67 .duration_since(UNIX_EPOCH)
68 .unwrap_or_default()
69 .as_secs();
70
71 Self {
72 text: text.into(),
73 status: CommandStatus::Pending,
74 output: String::new(),
75 exit_code: None,
76 created_at: now,
77 started_at: None,
78 finished_at: None,
79 }
80 }
81
82 pub fn start(&mut self) {
84 self.status = CommandStatus::Running;
85 let now = SystemTime::now()
86 .duration_since(UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_secs();
89 self.started_at = Some(now);
90 }
91
92 pub fn append_output(&mut self, output: &str) {
94 self.output.push_str(output);
95 }
96
97 pub fn complete(&mut self, exit_code: i32) {
99 self.status = if exit_code == 0 {
100 CommandStatus::Success
101 } else {
102 CommandStatus::Failed
103 };
104 self.exit_code = Some(exit_code);
105 let now = SystemTime::now()
106 .duration_since(UNIX_EPOCH)
107 .unwrap_or_default()
108 .as_secs();
109 self.finished_at = Some(now);
110 }
111
112 pub fn cancel(&mut self) {
114 self.status = CommandStatus::Cancelled;
115 let now = SystemTime::now()
116 .duration_since(UNIX_EPOCH)
117 .unwrap_or_default()
118 .as_secs();
119 self.finished_at = Some(now);
120 }
121
122 pub fn duration_secs(&self) -> Option<u64> {
124 match (self.started_at, self.finished_at) {
125 (Some(start), Some(end)) => Some(end - start),
126 (Some(start), None) => {
127 let now = SystemTime::now()
128 .duration_since(UNIX_EPOCH)
129 .unwrap_or_default()
130 .as_secs();
131 Some(now - start)
132 }
133 _ => None,
134 }
135 }
136
137 pub fn is_complete(&self) -> bool {
139 matches!(
140 self.status,
141 CommandStatus::Success | CommandStatus::Failed | CommandStatus::Cancelled
142 )
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct CommandBlock {
149 pub id: String,
151 pub title: String,
153 pub commands: Vec<Command>,
155 pub collapsed: bool,
157 pub selected_command: Option<usize>,
159 pub created_at: u64,
161}
162
163impl CommandBlock {
164 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
166 let now = SystemTime::now()
167 .duration_since(UNIX_EPOCH)
168 .unwrap_or_default()
169 .as_secs();
170
171 Self {
172 id: id.into(),
173 title: title.into(),
174 commands: Vec::new(),
175 collapsed: false,
176 selected_command: None,
177 created_at: now,
178 }
179 }
180
181 pub fn add_command(&mut self, command: Command) {
183 self.commands.push(command);
184 }
185
186 pub fn overall_status(&self) -> CommandStatus {
188 if self.commands.is_empty() {
189 return CommandStatus::Pending;
190 }
191
192 if self
194 .commands
195 .iter()
196 .any(|c| c.status == CommandStatus::Running)
197 {
198 return CommandStatus::Running;
199 }
200
201 if self
203 .commands
204 .iter()
205 .any(|c| c.status == CommandStatus::Failed)
206 {
207 return CommandStatus::Failed;
208 }
209
210 if self
212 .commands
213 .iter()
214 .any(|c| c.status == CommandStatus::Pending)
215 {
216 return CommandStatus::Pending;
217 }
218
219 if self
221 .commands
222 .iter()
223 .any(|c| c.status == CommandStatus::Cancelled)
224 {
225 return CommandStatus::Cancelled;
226 }
227
228 CommandStatus::Success
230 }
231
232 pub fn toggle_collapsed(&mut self) {
234 self.collapsed = !self.collapsed;
235 }
236
237 pub fn visible_commands(&self) -> Vec<&Command> {
239 if self.collapsed {
240 Vec::new()
241 } else {
242 self.commands.iter().collect()
243 }
244 }
245
246 pub fn select_next_command(&mut self) {
248 match self.selected_command {
249 None => {
250 if !self.commands.is_empty() {
251 self.selected_command = Some(0);
252 }
253 }
254 Some(idx) if idx < self.commands.len() - 1 => {
255 self.selected_command = Some(idx + 1);
256 }
257 _ => {}
258 }
259 }
260
261 pub fn select_prev_command(&mut self) {
263 match self.selected_command {
264 Some(idx) if idx > 0 => {
265 self.selected_command = Some(idx - 1);
266 }
267 Some(0) => {
268 self.selected_command = None;
269 }
270 _ => {}
271 }
272 }
273
274 pub fn get_selected_command(&self) -> Option<&Command> {
276 self.selected_command.and_then(|idx| self.commands.get(idx))
277 }
278
279 pub fn get_selected_command_mut(&mut self) -> Option<&mut Command> {
281 let idx = self.selected_command?;
282 self.commands.get_mut(idx)
283 }
284
285 pub fn total_duration_secs(&self) -> u64 {
287 self.commands.iter().filter_map(|c| c.duration_secs()).sum()
288 }
289
290 pub fn success_count(&self) -> usize {
292 self.commands
293 .iter()
294 .filter(|c| c.status == CommandStatus::Success)
295 .count()
296 }
297
298 pub fn failed_count(&self) -> usize {
300 self.commands
301 .iter()
302 .filter(|c| c.status == CommandStatus::Failed)
303 .count()
304 }
305
306 pub fn running_count(&self) -> usize {
308 self.commands
309 .iter()
310 .filter(|c| c.status == CommandStatus::Running)
311 .count()
312 }
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
317pub enum CopyActionType {
318 Block,
320 Command,
322 Output,
324}
325
326pub struct CommandBlocksWidget {
328 pub blocks: Vec<CommandBlock>,
330 pub selected_block: Option<usize>,
332 pub scroll: usize,
334 pub last_copy_result: Option<Result<CopyActionType, String>>,
336}
337
338impl CommandBlocksWidget {
339 pub fn new() -> Self {
341 Self {
342 blocks: Vec::new(),
343 selected_block: None,
344 scroll: 0,
345 last_copy_result: None,
346 }
347 }
348
349 pub fn add_block(&mut self, block: CommandBlock) {
351 self.blocks.push(block);
352 }
353
354 pub fn select_next_block(&mut self) {
356 match self.selected_block {
357 None => {
358 if !self.blocks.is_empty() {
359 self.selected_block = Some(0);
360 }
361 }
362 Some(idx) if idx < self.blocks.len() - 1 => {
363 self.selected_block = Some(idx + 1);
364 }
365 _ => {}
366 }
367 }
368
369 pub fn select_prev_block(&mut self) {
371 match self.selected_block {
372 Some(idx) if idx > 0 => {
373 self.selected_block = Some(idx - 1);
374 }
375 Some(0) => {
376 self.selected_block = None;
377 }
378 _ => {}
379 }
380 }
381
382 pub fn get_selected_block(&self) -> Option<&CommandBlock> {
384 self.selected_block.and_then(|idx| self.blocks.get(idx))
385 }
386
387 pub fn get_selected_block_mut(&mut self) -> Option<&mut CommandBlock> {
389 let idx = self.selected_block?;
390 self.blocks.get_mut(idx)
391 }
392
393 pub fn toggle_selected_block_collapsed(&mut self) {
395 if let Some(block) = self.get_selected_block_mut() {
396 block.toggle_collapsed();
397 }
398 }
399
400 pub fn clear(&mut self) {
402 self.blocks.clear();
403 self.selected_block = None;
404 self.scroll = 0;
405 }
406
407 pub fn total_commands(&self) -> usize {
409 self.blocks.iter().map(|b| b.commands.len()).sum()
410 }
411
412 pub fn total_success(&self) -> usize {
414 self.blocks.iter().map(|b| b.success_count()).sum()
415 }
416
417 pub fn total_failed(&self) -> usize {
419 self.blocks.iter().map(|b| b.failed_count()).sum()
420 }
421
422 pub fn total_running(&self) -> usize {
424 self.blocks.iter().map(|b| b.running_count()).sum()
425 }
426
427 pub fn copy_selected_block(&mut self, action_type: CopyActionType) -> Result<(), String> {
429 let block = self
430 .get_selected_block()
431 .ok_or_else(|| "No block selected".to_string())?;
432
433 let cmd = block
434 .get_selected_command()
435 .ok_or_else(|| "No command selected in block".to_string())?;
436
437 match action_type {
438 CopyActionType::Block => {
439 let content = format!(
440 "Command: {}\nStatus: {}\nOutput:\n{}",
441 cmd.text,
442 cmd.status.display_text(),
443 cmd.output
444 );
445 self.copy_to_clipboard(&content)?;
446 }
447 CopyActionType::Command => {
448 self.copy_to_clipboard(&cmd.text)?;
449 }
450 CopyActionType::Output => {
451 self.copy_to_clipboard(&cmd.output)?;
452 }
453 }
454
455 self.last_copy_result = Some(Ok(action_type));
456 Ok(())
457 }
458
459 pub fn copy_all_blocks(&mut self) -> Result<(), String> {
461 let mut content = String::new();
462
463 for (block_idx, block) in self.blocks.iter().enumerate() {
464 if block_idx > 0 {
465 content.push_str("\n\n");
466 }
467
468 content.push_str(&format!("=== Block: {} ===\n", block.title));
469
470 for cmd in &block.commands {
471 content.push_str(&format!(
472 "Command: {}\nStatus: {}\nOutput:\n{}\n\n",
473 cmd.text,
474 cmd.status.display_text(),
475 cmd.output
476 ));
477 }
478 }
479
480 self.copy_to_clipboard(&content)?;
481 self.last_copy_result = Some(Ok(CopyActionType::Block));
482 Ok(())
483 }
484
485 pub fn copy_block_by_index(
487 &mut self,
488 block_idx: usize,
489 action_type: CopyActionType,
490 ) -> Result<(), String> {
491 let block = self
492 .blocks
493 .get(block_idx)
494 .ok_or_else(|| format!("Block index {} out of range", block_idx))?;
495
496 match action_type {
497 CopyActionType::Block => {
498 let mut content = String::new();
499 for cmd in &block.commands {
500 content.push_str(&format!(
501 "Command: {}\nStatus: {}\nOutput:\n{}\n\n",
502 cmd.text,
503 cmd.status.display_text(),
504 cmd.output
505 ));
506 }
507 self.copy_to_clipboard(&content)?;
508 }
509 CopyActionType::Command => {
510 if let Some(cmd) = block.get_selected_command() {
511 self.copy_to_clipboard(&cmd.text)?;
512 } else {
513 return Err("No command selected in block".to_string());
514 }
515 }
516 CopyActionType::Output => {
517 if let Some(cmd) = block.get_selected_command() {
518 self.copy_to_clipboard(&cmd.output)?;
519 } else {
520 return Err("No command selected in block".to_string());
521 }
522 }
523 }
524
525 self.last_copy_result = Some(Ok(action_type));
526 Ok(())
527 }
528
529 fn copy_to_clipboard(&self, text: &str) -> Result<(), String> {
531 const MAX_SIZE: usize = 100 * 1024 * 1024;
533 if text.len() > MAX_SIZE {
534 return Err(format!(
535 "Content too large to copy: {} bytes (max: {} bytes)",
536 text.len(),
537 MAX_SIZE
538 ));
539 }
540
541 Ok(())
544 }
545
546 pub fn get_last_copy_result(&self) -> Option<&Result<CopyActionType, String>> {
548 self.last_copy_result.as_ref()
549 }
550
551 pub fn clear_copy_result(&mut self) {
553 self.last_copy_result = None;
554 }
555}
556
557impl Default for CommandBlocksWidget {
558 fn default() -> Self {
559 Self::new()
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 #[test]
568 fn test_command_creation() {
569 let cmd = Command::new("echo hello");
570 assert_eq!(cmd.text, "echo hello");
571 assert_eq!(cmd.status, CommandStatus::Pending);
572 assert!(cmd.output.is_empty());
573 assert_eq!(cmd.exit_code, None);
574 }
575
576 #[test]
577 fn test_command_lifecycle() {
578 let mut cmd = Command::new("echo hello");
579 assert_eq!(cmd.status, CommandStatus::Pending);
580
581 cmd.start();
582 assert_eq!(cmd.status, CommandStatus::Running);
583 assert!(cmd.started_at.is_some());
584
585 cmd.append_output("hello\n");
586 assert_eq!(cmd.output, "hello\n");
587
588 cmd.complete(0);
589 assert_eq!(cmd.status, CommandStatus::Success);
590 assert_eq!(cmd.exit_code, Some(0));
591 assert!(cmd.finished_at.is_some());
592 }
593
594 #[test]
595 fn test_command_failure() {
596 let mut cmd = Command::new("false");
597 cmd.start();
598 cmd.complete(1);
599 assert_eq!(cmd.status, CommandStatus::Failed);
600 assert_eq!(cmd.exit_code, Some(1));
601 }
602
603 #[test]
604 fn test_command_cancellation() {
605 let mut cmd = Command::new("sleep 10");
606 cmd.start();
607 cmd.cancel();
608 assert_eq!(cmd.status, CommandStatus::Cancelled);
609 assert!(cmd.finished_at.is_some());
610 }
611
612 #[test]
613 fn test_command_block_creation() {
614 let block = CommandBlock::new("block1", "Build commands");
615 assert_eq!(block.id, "block1");
616 assert_eq!(block.title, "Build commands");
617 assert!(block.commands.is_empty());
618 assert!(!block.collapsed);
619 }
620
621 #[test]
622 fn test_command_block_add_commands() {
623 let mut block = CommandBlock::new("block1", "Build");
624 let cmd1 = Command::new("cargo build");
625 let cmd2 = Command::new("cargo test");
626
627 block.add_command(cmd1);
628 block.add_command(cmd2);
629
630 assert_eq!(block.commands.len(), 2);
631 }
632
633 #[test]
634 fn test_command_block_overall_status() {
635 let mut block = CommandBlock::new("block1", "Build");
636
637 assert_eq!(block.overall_status(), CommandStatus::Pending);
639
640 let cmd1 = Command::new("cargo build");
642 block.add_command(cmd1);
643 assert_eq!(block.overall_status(), CommandStatus::Pending);
644
645 if let Some(cmd) = block.commands.get_mut(0) {
647 cmd.start();
648 }
649 assert_eq!(block.overall_status(), CommandStatus::Running);
650
651 if let Some(cmd) = block.commands.get_mut(0) {
653 cmd.complete(0);
654 }
655 assert_eq!(block.overall_status(), CommandStatus::Success);
656 }
657
658 #[test]
659 fn test_command_block_collapsed() {
660 let mut block = CommandBlock::new("block1", "Build");
661 let cmd = Command::new("cargo build");
662 block.add_command(cmd);
663
664 assert_eq!(block.visible_commands().len(), 1);
665
666 block.toggle_collapsed();
667 assert!(block.collapsed);
668 assert_eq!(block.visible_commands().len(), 0);
669
670 block.toggle_collapsed();
671 assert!(!block.collapsed);
672 assert_eq!(block.visible_commands().len(), 1);
673 }
674
675 #[test]
676 fn test_command_block_selection() {
677 let mut block = CommandBlock::new("block1", "Build");
678 block.add_command(Command::new("cmd1"));
679 block.add_command(Command::new("cmd2"));
680
681 assert_eq!(block.selected_command, None);
682
683 block.select_next_command();
684 assert_eq!(block.selected_command, Some(0));
685
686 block.select_next_command();
687 assert_eq!(block.selected_command, Some(1));
688
689 block.select_prev_command();
690 assert_eq!(block.selected_command, Some(0));
691
692 block.select_prev_command();
693 assert_eq!(block.selected_command, None);
694 }
695
696 #[test]
697 fn test_command_blocks_widget() {
698 let mut widget = CommandBlocksWidget::new();
699 assert!(widget.blocks.is_empty());
700
701 let block1 = CommandBlock::new("block1", "Build");
702 let block2 = CommandBlock::new("block2", "Test");
703
704 widget.add_block(block1);
705 widget.add_block(block2);
706
707 assert_eq!(widget.blocks.len(), 2);
708 }
709
710 #[test]
711 fn test_command_blocks_widget_selection() {
712 let mut widget = CommandBlocksWidget::new();
713 widget.add_block(CommandBlock::new("block1", "Build"));
714 widget.add_block(CommandBlock::new("block2", "Test"));
715
716 assert_eq!(widget.selected_block, None);
717
718 widget.select_next_block();
719 assert_eq!(widget.selected_block, Some(0));
720
721 widget.select_next_block();
722 assert_eq!(widget.selected_block, Some(1));
723
724 widget.select_prev_block();
725 assert_eq!(widget.selected_block, Some(0));
726 }
727
728 #[test]
729 fn test_command_blocks_widget_statistics() {
730 let mut widget = CommandBlocksWidget::new();
731 let mut block = CommandBlock::new("block1", "Build");
732
733 let mut cmd1 = Command::new("cmd1");
734 cmd1.complete(0);
735 block.add_command(cmd1);
736
737 let mut cmd2 = Command::new("cmd2");
738 cmd2.complete(1);
739 block.add_command(cmd2);
740
741 widget.add_block(block);
742
743 assert_eq!(widget.total_commands(), 2);
744 assert_eq!(widget.total_success(), 1);
745 assert_eq!(widget.total_failed(), 1);
746 }
747
748 #[test]
749 fn test_command_status_display() {
750 assert_eq!(CommandStatus::Pending.short_text(), "pending");
751 assert_eq!(CommandStatus::Running.short_text(), "running");
752 assert_eq!(CommandStatus::Success.short_text(), "success");
753 assert_eq!(CommandStatus::Failed.short_text(), "failed");
754 assert_eq!(CommandStatus::Cancelled.short_text(), "cancelled");
755 }
756
757 #[test]
758 fn test_copy_action_type() {
759 assert_eq!(CopyActionType::Block, CopyActionType::Block);
760 assert_eq!(CopyActionType::Command, CopyActionType::Command);
761 assert_eq!(CopyActionType::Output, CopyActionType::Output);
762 assert_ne!(CopyActionType::Block, CopyActionType::Command);
763 }
764
765 #[test]
766 fn test_command_blocks_widget_copy_selected_block() {
767 let mut widget = CommandBlocksWidget::new();
768 let mut block = CommandBlock::new("block1", "Build");
769 let mut cmd = Command::new("cargo build");
770 cmd.append_output("Compiling...\nFinished");
771 block.add_command(cmd);
772 widget.add_block(block);
773
774 widget.select_next_block();
775 if let Some(block) = widget.get_selected_block_mut() {
776 block.select_next_command();
777 }
778
779 let result = widget.copy_selected_block(CopyActionType::Command);
780 assert!(result.is_ok());
781 assert_eq!(
782 widget.get_last_copy_result(),
783 Some(&Ok(CopyActionType::Command))
784 );
785 }
786
787 #[test]
788 fn test_command_blocks_widget_copy_all_blocks() {
789 let mut widget = CommandBlocksWidget::new();
790
791 let mut block1 = CommandBlock::new("block1", "Build");
792 let mut cmd1 = Command::new("cargo build");
793 cmd1.append_output("Success");
794 block1.add_command(cmd1);
795 widget.add_block(block1);
796
797 let mut block2 = CommandBlock::new("block2", "Test");
798 let mut cmd2 = Command::new("cargo test");
799 cmd2.append_output("All tests passed");
800 block2.add_command(cmd2);
801 widget.add_block(block2);
802
803 let result = widget.copy_all_blocks();
804 assert!(result.is_ok());
805 assert_eq!(
806 widget.get_last_copy_result(),
807 Some(&Ok(CopyActionType::Block))
808 );
809 }
810
811 #[test]
812 fn test_command_blocks_widget_copy_block_by_index() {
813 let mut widget = CommandBlocksWidget::new();
814 let mut block = CommandBlock::new("block1", "Build");
815 let mut cmd = Command::new("cargo build");
816 cmd.append_output("Success");
817 block.add_command(cmd);
818 widget.add_block(block);
819
820 if let Some(block) = widget.blocks.get_mut(0) {
822 block.select_next_command();
823 }
824
825 let result = widget.copy_block_by_index(0, CopyActionType::Output);
826 assert!(result.is_ok());
827 }
828
829 #[test]
830 fn test_command_blocks_widget_copy_no_block_selected() {
831 let mut widget = CommandBlocksWidget::new();
832 let result = widget.copy_selected_block(CopyActionType::Command);
833 assert!(result.is_err());
834 assert_eq!(result.unwrap_err(), "No block selected");
835 }
836
837 #[test]
838 fn test_command_blocks_widget_copy_result_tracking() {
839 let mut widget = CommandBlocksWidget::new();
840 assert_eq!(widget.get_last_copy_result(), None);
841
842 let mut block = CommandBlock::new("block1", "Build");
843 let cmd = Command::new("cargo build");
844 block.add_command(cmd);
845 widget.add_block(block);
846
847 widget.select_next_block();
848 if let Some(block) = widget.get_selected_block_mut() {
849 block.select_next_command();
850 }
851
852 let _ = widget.copy_selected_block(CopyActionType::Command);
853 assert!(widget.get_last_copy_result().is_some());
854
855 widget.clear_copy_result();
856 assert_eq!(widget.get_last_copy_result(), None);
857 }
858
859 #[test]
860 fn test_command_blocks_widget_copy_size_limit() {
861 let mut widget = CommandBlocksWidget::new();
862 let mut block = CommandBlock::new("block1", "Build");
863 let mut cmd = Command::new("cargo build");
864
865 let large_output = "x".repeat(101 * 1024 * 1024);
867 cmd.append_output(&large_output);
868 block.add_command(cmd);
869 widget.add_block(block);
870
871 widget.select_next_block();
872 if let Some(block) = widget.get_selected_block_mut() {
873 block.select_next_command();
874 }
875
876 let result = widget.copy_selected_block(CopyActionType::Output);
877 assert!(result.is_err());
878 }
879}