use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TaskStatus {
Pending,
Running,
Completed,
Failed,
Skipped,
Cached,
}
impl TaskStatus {
#[must_use]
pub const fn symbol(self) -> &'static str {
match self {
Self::Pending => "⏸",
Self::Running => "▶",
Self::Completed => "✓",
Self::Failed => "✗",
Self::Skipped => "⊘",
Self::Cached => "⚡",
}
}
#[must_use]
pub const fn color(self) -> ratatui::style::Color {
use ratatui::style::Color;
match self {
Self::Running => Color::Yellow,
Self::Completed => Color::Green,
Self::Failed => Color::Red,
Self::Pending | Self::Skipped => Color::DarkGray,
Self::Cached => Color::Cyan,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputMode {
#[default]
All,
Selected,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeNodeType {
All,
Group(String),
Task(String),
}
#[derive(Debug, Clone)]
pub struct TreeViewItem {
pub node_type: TreeNodeType,
pub display_name: String,
pub depth: usize,
pub is_expanded: bool,
pub has_children: bool,
}
impl TreeViewItem {
#[must_use]
pub fn node_key(&self) -> String {
match &self.node_type {
TreeNodeType::All => "::all::".to_string(),
TreeNodeType::Group(path) => format!("::group::{path}"),
TreeNodeType::Task(name) => name.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct TaskInfo {
pub name: String,
pub status: TaskStatus,
pub dependencies: Vec<String>,
pub level: usize,
pub start_time: Option<Instant>,
pub duration_ms: Option<u64>,
pub exit_code: Option<i32>,
}
impl TaskInfo {
#[must_use]
pub const fn new(name: String, dependencies: Vec<String>, level: usize) -> Self {
Self {
name,
status: TaskStatus::Pending,
dependencies,
level,
start_time: None,
duration_ms: None,
exit_code: None,
}
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn elapsed_ms(&self) -> Option<u64> {
self.start_time
.map(|start| start.elapsed().as_millis() as u64)
}
}
const MAX_OUTPUT_LINES: usize = 1000;
#[derive(Debug, Clone)]
pub struct OutputLine {
pub content: String,
pub is_stderr: bool,
}
#[derive(Debug, Clone)]
pub struct TaskOutput {
pub name: String,
pub stdout: VecDeque<String>,
pub stderr: VecDeque<String>,
pub combined: VecDeque<OutputLine>,
pub stdout_dirty: bool,
pub stderr_dirty: bool,
}
impl TaskOutput {
#[must_use]
pub const fn new(name: String) -> Self {
Self {
name,
stdout: VecDeque::new(),
stderr: VecDeque::new(),
combined: VecDeque::new(),
stdout_dirty: false,
stderr_dirty: false,
}
}
fn add_line(&mut self, line: String, is_stderr: bool) {
let (buffer, dirty_flag) = if is_stderr {
(&mut self.stderr, &mut self.stderr_dirty)
} else {
(&mut self.stdout, &mut self.stdout_dirty)
};
if buffer.len() >= MAX_OUTPUT_LINES {
buffer.pop_front();
}
buffer.push_back(line.clone());
if self.combined.len() >= MAX_OUTPUT_LINES {
self.combined.pop_front();
}
self.combined.push_back(OutputLine {
content: line,
is_stderr,
});
*dirty_flag = true;
}
pub fn add_stdout(&mut self, line: String) {
self.add_line(line, false);
}
pub fn add_stderr(&mut self, line: String) {
self.add_line(line, true);
}
pub const fn clear_dirty(&mut self) {
self.stdout_dirty = false;
self.stderr_dirty = false;
}
}
#[derive(Debug)]
pub struct TuiState {
pub start_time: Instant,
pub tasks: HashMap<String, TaskInfo>,
pub outputs: HashMap<String, TaskOutput>,
pub running_tasks: Vec<String>,
pub is_complete: bool,
pub success: bool,
pub error_message: Option<String>,
pub selected_task: Option<String>,
pub expanded_nodes: HashSet<String>,
pub cursor_position: usize,
pub flattened_tree: Vec<TreeViewItem>,
pub output_mode: OutputMode,
pub output_scroll: usize,
}
impl TuiState {
#[must_use]
pub fn new() -> Self {
Self {
start_time: Instant::now(),
tasks: HashMap::new(),
outputs: HashMap::new(),
running_tasks: Vec::new(),
is_complete: false,
success: false,
error_message: None,
selected_task: None,
expanded_nodes: HashSet::new(),
cursor_position: 0,
flattened_tree: Vec::new(),
output_mode: OutputMode::All,
output_scroll: 0,
}
}
pub fn add_task(&mut self, task: TaskInfo) {
let name = task.name.clone();
self.tasks.insert(name.clone(), task);
self.outputs.insert(name.clone(), TaskOutput::new(name));
}
pub fn update_task_status(&mut self, name: &str, status: TaskStatus) {
if let Some(task) = self.tasks.get_mut(name) {
task.status = status;
match status {
TaskStatus::Running => {
task.start_time = Some(Instant::now());
if !self.running_tasks.contains(&name.to_string()) {
self.running_tasks.push(name.to_string());
}
}
TaskStatus::Completed
| TaskStatus::Failed
| TaskStatus::Cached
| TaskStatus::Skipped => {
if let Some(start) = task.start_time {
#[allow(clippy::cast_possible_truncation)]
let duration = start.elapsed().as_millis() as u64;
task.duration_ms = Some(duration);
}
self.running_tasks.retain(|t| t != name);
}
TaskStatus::Pending => {
}
}
} else {
tracing::warn!(
"Attempted to update status for unknown task '{}' to {:?}",
name,
status
);
}
}
pub fn add_task_output(&mut self, name: &str, stream: &str, content: String) {
if let Some(output) = self.outputs.get_mut(name) {
match stream {
"stdout" => output.add_stdout(content),
"stderr" => output.add_stderr(content),
unknown => {
tracing::debug!(
"Unknown stream type '{}' for task '{}', treating as stdout",
unknown,
name
);
output.add_stdout(content);
}
}
} else {
let preview: String = content.chars().take(50).collect();
tracing::warn!(
"Received output for unknown task '{}': {}...",
name,
preview
);
}
}
#[must_use]
#[allow(clippy::cast_possible_truncation)]
pub fn elapsed_ms(&self) -> u64 {
self.start_time.elapsed().as_millis() as u64
}
pub fn complete(&mut self, success: bool, error_message: Option<String>) {
self.is_complete = true;
self.success = success;
self.error_message = error_message;
}
#[must_use]
fn parse_task_path(task_name: &str) -> Vec<&str> {
let parts: Vec<&str> = task_name.split(':').collect();
if parts.len() >= 3 {
parts[2].split('.').collect()
} else if parts.len() == 2 {
parts[1].split('.').collect()
} else {
vec![task_name]
}
}
fn build_name_hierarchy(&self) -> HashMap<String, (Vec<String>, Vec<String>)> {
let mut tree: HashMap<String, (Vec<String>, Vec<String>)> = HashMap::new();
tree.insert(String::new(), (Vec::new(), Vec::new()));
for task_name in self.tasks.keys() {
let path_parts = Self::parse_task_path(task_name);
let mut current_path = String::new();
let group_parts = if path_parts.len() > 1 {
&path_parts[..path_parts.len() - 1]
} else {
&[] };
for part in group_parts {
let parent_path = current_path.clone();
if current_path.is_empty() {
current_path = (*part).to_string();
} else {
current_path = format!("{current_path}.{part}");
}
tree.entry(current_path.clone())
.or_insert_with(|| (Vec::new(), Vec::new()));
let (groups, _) = tree
.entry(parent_path)
.or_insert_with(|| (Vec::new(), Vec::new()));
if !groups.contains(¤t_path) {
groups.push(current_path.clone());
}
}
let parent_path = if path_parts.len() > 1 {
path_parts[..path_parts.len() - 1].join(".")
} else {
String::new() };
let (_, tasks) = tree
.entry(parent_path)
.or_insert_with(|| (Vec::new(), Vec::new()));
if !tasks.contains(task_name) {
tasks.push(task_name.clone());
}
}
for (groups, tasks) in tree.values_mut() {
groups.sort();
tasks.sort();
}
tree
}
pub fn rebuild_flattened_tree(&mut self) {
let tree = self.build_name_hierarchy();
let mut flattened = Vec::new();
let all_key = "::all::".to_string();
let all_expanded = self.expanded_nodes.contains(&all_key);
let has_tasks = !self.tasks.is_empty();
flattened.push(TreeViewItem {
node_type: TreeNodeType::All,
display_name: "All".to_string(),
depth: 0,
is_expanded: all_expanded,
has_children: has_tasks,
});
if all_expanded {
if let Some((root_groups, root_tasks)) = tree.get("") {
let mut stack: Vec<(String, String, usize, bool)> = Vec::new();
for task_name in root_tasks.iter().rev() {
let path_parts = Self::parse_task_path(task_name);
let display = path_parts
.last()
.map_or(task_name.clone(), |s| (*s).to_string());
stack.push((task_name.clone(), display, 1, false));
}
for group_path in root_groups.iter().rev() {
let display = group_path
.split('.')
.next()
.unwrap_or(group_path)
.to_string();
stack.push((group_path.clone(), display, 1, true));
}
while let Some((path, display_name, depth, is_group)) = stack.pop() {
if is_group {
let group_key = format!("::group::{path}");
let is_expanded = self.expanded_nodes.contains(&group_key);
let empty_vec: Vec<String> = Vec::new();
let (child_groups, child_tasks) = tree
.get(&path)
.map_or((&empty_vec, &empty_vec), |(g, t)| (g, t));
let has_children = !child_groups.is_empty() || !child_tasks.is_empty();
flattened.push(TreeViewItem {
node_type: TreeNodeType::Group(path.clone()),
display_name,
depth,
is_expanded,
has_children,
});
if is_expanded {
for task_name in child_tasks.iter().rev() {
let path_parts = Self::parse_task_path(task_name);
let task_display = path_parts
.last()
.map_or(task_name.clone(), |s| (*s).to_string());
stack.push((task_name.clone(), task_display, depth + 1, false));
}
for child_path in child_groups.iter().rev() {
let child_display = child_path
.split('.')
.next_back()
.unwrap_or(child_path)
.to_string();
stack.push((child_path.clone(), child_display, depth + 1, true));
}
}
} else {
flattened.push(TreeViewItem {
node_type: TreeNodeType::Task(path.clone()),
display_name,
depth,
is_expanded: false,
has_children: false,
});
}
}
}
}
self.flattened_tree = flattened;
if self.cursor_position >= self.flattened_tree.len() {
self.cursor_position = self.flattened_tree.len().saturating_sub(1);
}
}
pub fn toggle_expansion(&mut self, node_key: &str) {
if self.expanded_nodes.contains(node_key) {
self.expanded_nodes.remove(node_key);
} else {
self.expanded_nodes.insert(node_key.to_string());
}
self.rebuild_flattened_tree();
}
pub const fn cursor_up(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
pub const fn cursor_down(&mut self) {
if self.cursor_position < self.flattened_tree.len().saturating_sub(1) {
self.cursor_position += 1;
}
}
#[must_use]
pub fn highlighted_node(&self) -> Option<&TreeViewItem> {
self.flattened_tree.get(self.cursor_position)
}
pub fn select_current_node(&mut self) {
if let Some(node) = self.highlighted_node() {
match &node.node_type {
TreeNodeType::All => {
self.selected_task = None;
self.output_mode = OutputMode::All;
}
TreeNodeType::Task(name) => {
self.selected_task = Some(name.clone());
self.output_mode = OutputMode::Selected;
}
TreeNodeType::Group(path) => {
self.selected_task = Some(format!("::group::{path}"));
self.output_mode = OutputMode::Selected;
}
}
self.output_scroll = 0;
}
}
pub fn show_all_output(&mut self) {
self.selected_task = None;
self.output_mode = OutputMode::All;
self.output_scroll = 0;
}
pub fn init_tree(&mut self) {
self.expanded_nodes.insert("::all::".to_string());
self.rebuild_flattened_tree();
}
}
impl Default for TuiState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_status_symbol() {
assert_eq!(TaskStatus::Pending.symbol(), "⏸");
assert_eq!(TaskStatus::Running.symbol(), "▶");
assert_eq!(TaskStatus::Completed.symbol(), "✓");
assert_eq!(TaskStatus::Failed.symbol(), "✗");
assert_eq!(TaskStatus::Skipped.symbol(), "⊘");
assert_eq!(TaskStatus::Cached.symbol(), "⚡");
}
#[test]
fn test_task_info_new() {
let task = TaskInfo::new("test".to_string(), vec!["dep1".to_string()], 1);
assert_eq!(task.name, "test");
assert_eq!(task.status, TaskStatus::Pending);
assert_eq!(task.level, 1);
assert!(task.start_time.is_none());
assert!(task.duration_ms.is_none());
}
#[test]
fn test_task_output_new() {
let output = TaskOutput::new("test".to_string());
assert_eq!(output.name, "test");
assert!(output.stdout.is_empty());
assert!(output.stderr.is_empty());
assert!(!output.stdout_dirty);
assert!(!output.stderr_dirty);
}
#[test]
fn test_task_output_add() {
let mut output = TaskOutput::new("test".to_string());
output.add_stdout("line1".to_string());
output.add_stderr("error1".to_string());
assert_eq!(output.stdout.len(), 1);
assert_eq!(output.stderr.len(), 1);
assert!(output.stdout_dirty);
assert!(output.stderr_dirty);
output.clear_dirty();
assert!(!output.stdout_dirty);
assert!(!output.stderr_dirty);
}
#[test]
fn test_tui_state_new() {
let state = TuiState::new();
assert!(state.tasks.is_empty());
assert!(state.outputs.is_empty());
assert!(state.running_tasks.is_empty());
assert!(!state.is_complete);
assert!(!state.success);
}
#[test]
fn test_tui_state_add_task() {
let mut state = TuiState::new();
let task = TaskInfo::new("test".to_string(), vec![], 0);
state.add_task(task);
assert_eq!(state.tasks.len(), 1);
assert_eq!(state.outputs.len(), 1);
assert!(state.tasks.contains_key("test"));
assert!(state.outputs.contains_key("test"));
}
#[test]
fn test_tui_state_update_status() {
let mut state = TuiState::new();
let task = TaskInfo::new("test".to_string(), vec![], 0);
state.add_task(task);
state.update_task_status("test", TaskStatus::Running);
assert_eq!(state.tasks.get("test").unwrap().status, TaskStatus::Running);
assert_eq!(state.running_tasks.len(), 1);
state.update_task_status("test", TaskStatus::Completed);
assert_eq!(
state.tasks.get("test").unwrap().status,
TaskStatus::Completed
);
assert_eq!(state.running_tasks.len(), 0);
}
#[test]
fn test_tui_state_parse_task_path() {
let path = TuiState::parse_task_path("task:cuenv:test.bdd");
assert_eq!(path, vec!["test", "bdd"]);
let path = TuiState::parse_task_path("task:cuenv:build");
assert_eq!(path, vec!["build"]);
let path = TuiState::parse_task_path("cuenv:test.unit");
assert_eq!(path, vec!["test", "unit"]);
}
#[test]
fn test_tui_state_name_hierarchy() {
let mut state = TuiState::new();
state.add_task(TaskInfo::new("task:proj:test.bdd".to_string(), vec![], 0));
state.add_task(TaskInfo::new("task:proj:test.unit".to_string(), vec![], 0));
state.add_task(TaskInfo::new("task:proj:build".to_string(), vec![], 0));
let tree = state.build_name_hierarchy();
let (root_groups, root_tasks) = tree.get("").unwrap();
assert!(root_groups.contains(&"test".to_string()));
assert!(root_tasks.contains(&"task:proj:build".to_string()));
let (_, test_tasks) = tree.get("test").unwrap();
assert_eq!(test_tasks.len(), 2);
}
#[test]
fn test_tui_state_tree_navigation() {
let mut state = TuiState::new();
state.add_task(TaskInfo::new("task:proj:test.bdd".to_string(), vec![], 0));
state.add_task(TaskInfo::new("task:proj:test.unit".to_string(), vec![], 0));
state.init_tree();
assert_eq!(state.cursor_position, 0);
let node = state.highlighted_node().unwrap();
assert!(matches!(node.node_type, TreeNodeType::All));
state.cursor_down();
assert_eq!(state.cursor_position, 1);
state.cursor_up();
assert_eq!(state.cursor_position, 0);
state.cursor_up();
assert_eq!(state.cursor_position, 0);
}
#[test]
fn test_tui_state_output_mode() {
let mut state = TuiState::new();
state.add_task(TaskInfo::new("task:proj:build".to_string(), vec![], 0));
state.init_tree();
assert_eq!(state.output_mode, OutputMode::All);
state.cursor_down(); state.select_current_node();
assert_eq!(state.output_mode, OutputMode::Selected);
state.show_all_output();
assert_eq!(state.output_mode, OutputMode::All);
assert!(state.selected_task.is_none());
}
#[test]
fn test_tui_state_complete() {
let mut state = TuiState::new();
state.complete(true, None);
assert!(state.is_complete);
assert!(state.success);
assert!(state.error_message.is_none());
let mut state2 = TuiState::new();
state2.complete(false, Some("error".to_string()));
assert!(state2.is_complete);
assert!(!state2.success);
assert_eq!(state2.error_message, Some("error".to_string()));
}
#[test]
fn test_task_output_bounded_buffer() {
let mut output = TaskOutput::new("test".to_string());
for i in 0..MAX_OUTPUT_LINES + 100 {
output.add_stdout(format!("stdout line {i}"));
output.add_stderr(format!("stderr line {i}"));
}
assert_eq!(output.stdout.len(), MAX_OUTPUT_LINES);
assert_eq!(output.stderr.len(), MAX_OUTPUT_LINES);
assert_eq!(output.combined.len(), MAX_OUTPUT_LINES);
assert_eq!(
output.stdout.back().unwrap(),
&format!("stdout line {}", MAX_OUTPUT_LINES + 99)
);
assert_eq!(
output.stderr.back().unwrap(),
&format!("stderr line {}", MAX_OUTPUT_LINES + 99)
);
}
#[test]
fn test_task_output_chronological_order() {
let mut output = TaskOutput::new("test".to_string());
output.add_stdout("first stdout".to_string());
output.add_stderr("first stderr".to_string());
output.add_stdout("second stdout".to_string());
output.add_stderr("second stderr".to_string());
assert_eq!(output.combined.len(), 4);
assert_eq!(output.combined[0].content, "first stdout");
assert!(!output.combined[0].is_stderr);
assert_eq!(output.combined[1].content, "first stderr");
assert!(output.combined[1].is_stderr);
assert_eq!(output.combined[2].content, "second stdout");
assert!(!output.combined[2].is_stderr);
assert_eq!(output.combined[3].content, "second stderr");
assert!(output.combined[3].is_stderr);
}
}