use cuenv_core::tasks::{IndexedTask, TaskNode as CoreTaskNode};
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct TaskListData {
pub sources: Vec<TaskSourceGroup>,
pub stats: TaskListStats,
}
#[derive(Debug, Clone)]
pub struct TaskSourceGroup {
pub source: String,
pub header: String,
pub nodes: Vec<TaskNode>,
}
#[derive(Debug, Clone)]
pub struct TaskNode {
pub name: String,
pub full_name: Option<String>,
pub description: Option<String>,
pub is_group: bool,
pub dep_count: usize,
pub is_cached: bool,
pub children: Vec<Self>,
}
#[derive(Debug, Clone, Default)]
pub struct TaskListStats {
pub total_tasks: usize,
pub total_groups: usize,
pub cached_count: usize,
}
pub trait TaskListFormatter {
#[must_use]
fn format(&self, data: &TaskListData) -> String;
}
#[must_use]
pub fn build_task_list(
tasks: &[&IndexedTask],
cwd_relative: Option<&str>,
project_root: &Path,
) -> TaskListData {
let cached_tasks = collect_cached_tasks(tasks, project_root);
let mut by_source: BTreeMap<String, Vec<&IndexedTask>> = BTreeMap::new();
for task in tasks {
let source = task.source_file.clone().unwrap_or_default();
let normalized = if source == "env.cue" {
String::new()
} else {
source
};
by_source.entry(normalized).or_default().push(task);
}
let mut sources: Vec<_> = by_source.keys().cloned().collect();
sources.sort_by(|a, b| {
let proximity_a = source_proximity(a, cwd_relative);
let proximity_b = source_proximity(b, cwd_relative);
proximity_a.cmp(&proximity_b).then(a.cmp(b))
});
let mut source_groups = Vec::new();
let mut stats = TaskListStats::default();
for source in sources {
let source_tasks = &by_source[&source];
let header = if source.is_empty() || source == "env.cue" {
"Tasks".to_string()
} else {
format!("Tasks from {source}")
};
let (nodes, group_stats) = build_tree_nodes(source_tasks, &cached_tasks);
stats.total_tasks += group_stats.total_tasks;
stats.total_groups += group_stats.total_groups;
stats.cached_count += group_stats.cached_count;
source_groups.push(TaskSourceGroup {
source,
header,
nodes,
});
}
TaskListData {
sources: source_groups,
stats,
}
}
fn collect_cached_tasks(_tasks: &[&IndexedTask], _project_root: &Path) -> HashSet<String> {
HashSet::new()
}
#[derive(Default)]
struct TreeBuilder {
name: String,
full_name: Option<String>,
description: Option<String>,
is_task: bool,
is_cached: bool,
dep_count: usize,
children: BTreeMap<String, Self>,
}
fn convert(builders: BTreeMap<String, TreeBuilder>, stats: &mut TaskListStats) -> Vec<TaskNode> {
builders
.into_values()
.map(|builder| {
let is_group = !builder.is_task;
if is_group {
stats.total_groups += 1;
}
let child_nodes = convert(builder.children, stats);
let group_cached = builder.is_cached || child_nodes.iter().any(|c| c.is_cached);
TaskNode {
name: builder.name,
full_name: builder.full_name,
description: builder.description,
is_group,
dep_count: builder.dep_count,
is_cached: group_cached,
children: child_nodes,
}
})
.collect()
}
fn build_tree_nodes(
tasks: &[&IndexedTask],
cached_tasks: &HashSet<String>,
) -> (Vec<TaskNode>, TaskListStats) {
let mut roots: BTreeMap<String, TreeBuilder> = BTreeMap::new();
let mut stats = TaskListStats::default();
for task in tasks {
let parts: Vec<&str> = task.name.split('.').collect();
let mut current_level = &mut roots;
for (i, part) in parts.iter().enumerate() {
let is_last = i == parts.len() - 1;
let node = current_level.entry((*part).to_string()).or_default();
node.name = (*part).to_string();
if is_last {
node.is_task = true;
node.full_name = Some(task.name.clone());
node.dep_count = get_dep_count(&task.node);
node.is_cached = cached_tasks.contains(&task.name);
node.description = match &task.node {
CoreTaskNode::Task(t) => t.description.clone(),
CoreTaskNode::Group(g) => g.description.clone(),
CoreTaskNode::Sequence(_) => None,
};
stats.total_tasks += 1;
if node.is_cached {
stats.cached_count += 1;
}
}
current_level = &mut node.children;
}
}
let nodes = convert(roots, &mut stats);
(nodes, stats)
}
fn get_dep_count(node: &CoreTaskNode) -> usize {
match node {
CoreTaskNode::Task(t) => t.depends_on.len(),
CoreTaskNode::Group(g) => {
g.children.values().next().map_or(0, get_dep_count)
}
CoreTaskNode::Sequence(steps) => {
steps.first().map_or(0, get_dep_count)
}
}
}
fn source_proximity(source: &str, cwd_relative: Option<&str>) -> usize {
let source_dir = if source.is_empty() {
""
} else {
std::path::Path::new(source)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("")
};
let cwd = cwd_relative.unwrap_or("");
if source_dir.is_empty() && cwd.is_empty() {
return 0;
}
if source_dir == cwd {
return 0;
}
if cwd.starts_with(source_dir)
&& (source_dir.is_empty() || cwd[source_dir.len()..].starts_with('/'))
{
let source_depth = if source_dir.is_empty() {
0
} else {
source_dir.matches('/').count() + 1
};
let cwd_depth = if cwd.is_empty() {
0
} else {
cwd.matches('/').count() + 1
};
return cwd_depth - source_depth;
}
usize::MAX / 2
}
#[derive(Debug, Default)]
pub struct TextFormatter;
impl TaskListFormatter for TextFormatter {
fn format(&self, data: &TaskListData) -> String {
use std::fmt::Write;
let mut output = String::new();
for (i, group) in data.sources.iter().enumerate() {
if i > 0 {
output.push('\n');
}
if group.source.is_empty() {
let _ = writeln!(output, "{}:", group.header);
} else {
let _ = writeln!(output, "{} ({}):", group.header, group.source);
}
let max_width = calculate_max_width(&group.nodes, 0);
format_text_nodes(&group.nodes, &mut output, max_width, "");
}
if output.is_empty() {
output = "No tasks defined in the configuration".to_string();
} else {
let _ = writeln!(
output,
"\n({} tasks, {} groups, {} cached)",
data.stats.total_tasks, data.stats.total_groups, data.stats.cached_count
);
}
output
}
}
fn calculate_max_width(nodes: &[TaskNode], depth: usize) -> usize {
let mut max = 0;
for node in nodes {
let len = (depth * 3) + 3 + node.name.len();
if len > max {
max = len;
}
let child_max = calculate_max_width(&node.children, depth + 1);
if child_max > max {
max = child_max;
}
}
max
}
fn format_text_nodes(nodes: &[TaskNode], output: &mut String, max_width: usize, prefix: &str) {
use std::fmt::Write;
let count = nodes.len();
for (i, node) in nodes.iter().enumerate() {
let is_last = i == count - 1;
let marker = if is_last { "โโ " } else { "โโ " };
let display_name = if node.is_group {
&node.name
} else {
node.full_name.as_deref().unwrap_or(&node.name)
};
let current_len =
prefix.chars().count() + marker.chars().count() + display_name.chars().count();
let _ = write!(output, "{prefix}{marker}{display_name}");
if node.dep_count > 0 {
let _ = write!(output, " [{}]", node.dep_count);
}
if let Some(desc) = &node.description {
let padding = max_width.saturating_sub(current_len);
let dots = ".".repeat(padding + 4);
let _ = write!(output, " {dots} {desc}");
}
let _ = writeln!(output);
let child_prefix = if is_last { " " } else { "โ " };
let new_prefix = format!("{prefix}{child_prefix}");
format_text_nodes(&node.children, output, max_width, &new_prefix);
}
}
#[derive(Debug)]
pub struct RichFormatter {
pub use_colors: bool,
}
impl Default for RichFormatter {
fn default() -> Self {
Self { use_colors: true }
}
}
impl RichFormatter {
#[must_use]
pub fn new() -> Self {
use std::io::IsTerminal;
let use_colors = std::io::stdout().is_terminal();
Self { use_colors }
}
fn cyan(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[36m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn dim(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[2m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn bold(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
}
impl TaskListFormatter for RichFormatter {
fn format(&self, data: &TaskListData) -> String {
use std::fmt::Write;
let mut output = String::new();
for (i, group) in data.sources.iter().enumerate() {
if i > 0 {
output.push('\n');
}
let _ = writeln!(output, "{}:", self.bold(&group.header));
let max_width = calculate_max_width(&group.nodes, 0);
format_rich_nodes(self, &group.nodes, &mut output, max_width, "");
}
if output.is_empty() {
output = "No tasks defined in the configuration".to_string();
}
output
}
}
fn format_rich_nodes(
formatter: &RichFormatter,
nodes: &[TaskNode],
output: &mut String,
max_width: usize,
prefix: &str,
) {
use std::fmt::Write;
let count = nodes.len();
for (i, node) in nodes.iter().enumerate() {
let is_last = i == count - 1;
let marker = if is_last { "โโ " } else { "โโ " };
let current_len =
prefix.chars().count() + marker.chars().count() + node.name.chars().count();
let _ = write!(output, "{prefix}{}", formatter.dim(marker));
let colored_name = if node.is_group {
formatter.dim(&node.name)
} else {
formatter.cyan(&node.name)
};
let _ = write!(output, "{colored_name}");
if let Some(desc) = &node.description {
let padding = max_width.saturating_sub(current_len);
let dots = formatter.dim(&".".repeat(padding + 4));
let _ = write!(output, " {dots} {desc}");
}
let _ = writeln!(output);
let child_prefix = if is_last {
format!("{prefix} ")
} else {
format!("{prefix}{} ", formatter.dim("โ"))
};
format_rich_nodes(formatter, &node.children, output, max_width, &child_prefix);
}
}
#[derive(Debug, Default)]
pub struct TablesFormatter {
pub use_colors: bool,
}
impl TablesFormatter {
#[must_use]
pub fn new() -> Self {
use std::io::IsTerminal;
let use_colors = std::io::stdout().is_terminal();
Self { use_colors }
}
fn cyan(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[36m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn bold(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
}
impl TaskListFormatter for TablesFormatter {
fn format(&self, data: &TaskListData) -> String {
use std::fmt::Write;
let mut output = String::new();
if data.sources.is_empty() || data.stats.total_tasks == 0 {
return "No tasks defined in the configuration".to_string();
}
let mut categorized_tasks: CategorizedTasks = BTreeMap::new();
for group in &data.sources {
collect_tasks_for_tables(&group.nodes, &mut categorized_tasks, "");
}
for (i, (category, tasks)) in categorized_tasks.iter().enumerate() {
if i > 0 {
output.push('\n');
}
let max_name = tasks
.iter()
.map(|(n, _, _)| n.len())
.max()
.unwrap_or(10)
.max(10);
let max_desc = tasks
.iter()
.map(|(_, d, _)| d.len())
.max()
.unwrap_or(20)
.max(20);
let dep_width = 7;
let total_width = max_name + max_desc + dep_width + 6;
let _ = writeln!(output, "โญโ{}โโฎ", "โ".repeat(total_width));
let category_upper = category.to_uppercase();
let category_display = self.bold(&category_upper);
let padding = total_width.saturating_sub(category_upper.len()); let _ = writeln!(output, "โ {}{} โ", category_display, " ".repeat(padding));
let _ = write!(output, "โโ");
let _ = write!(output, "{}โโฌโ", "โ".repeat(max_name));
let _ = write!(output, "{}โโฌโ", "โ".repeat(max_desc));
let _ = writeln!(output, "{}โโค", "โ".repeat(dep_width));
for (name, desc, dep_count) in tasks {
let name_display = self.cyan(name);
let name_padding = max_name.saturating_sub(name.len()); let desc_padding = max_desc.saturating_sub(desc.len());
let dep_display = if *dep_count > 0 {
format!("{} dep{}", dep_count, if *dep_count > 1 { "s" } else { "" })
} else {
String::new()
};
let dep_padding = dep_width.saturating_sub(dep_display.len());
let _ = writeln!(
output,
"โ {}{} โ {}{} โ {}{} โ",
name_display,
" ".repeat(name_padding),
desc,
" ".repeat(desc_padding),
dep_display,
" ".repeat(dep_padding)
);
}
let _ = write!(output, "โฐโ");
let _ = write!(output, "{}โโดโ", "โ".repeat(max_name));
let _ = write!(output, "{}โโดโ", "โ".repeat(max_desc));
let _ = writeln!(output, "{}โโฏ", "โ".repeat(dep_width));
}
let _ = writeln!(
output,
"\n{} tasks in {} categories",
data.stats.total_tasks,
categorized_tasks.len()
);
output
}
}
type CategorizedTasks = BTreeMap<String, Vec<(String, String, usize)>>;
fn collect_tasks_for_tables(nodes: &[TaskNode], categories: &mut CategorizedTasks, prefix: &str) {
for node in nodes {
let full_name = if prefix.is_empty() {
node.name.clone()
} else {
format!("{prefix}.{}", node.name)
};
if !node.is_group {
let category = if full_name.contains('.') {
let parts: Vec<&str> = full_name.split('.').collect();
format_category_name(parts[0])
} else {
"General".to_string()
};
let task_name = node.full_name.as_deref().unwrap_or(&node.name);
let description = node.description.as_deref().unwrap_or("").to_string();
categories.entry(category).or_default().push((
task_name.to_string(),
description,
node.dep_count,
));
}
collect_tasks_for_tables(&node.children, categories, &full_name);
}
}
fn format_category_name(prefix: &str) -> String {
match prefix {
"build" => "Build & Compile".to_string(),
"test" => "Testing".to_string(),
"lint" | "fmt" | "check" => "Code Quality".to_string(),
"cargo" | "bun" | "npm" | "go" => format!("{} Tasks", prefix.to_uppercase()),
"security" | "audit" => "Security".to_string(),
"publish" | "release" | "deploy" => "Release".to_string(),
"docker" | "container" => "Containers".to_string(),
"ci" | "cd" => "CI/CD".to_string(),
_ => format!("{} Tasks", prefix),
}
}
#[derive(Debug, Default)]
pub struct DashboardFormatter {
pub use_colors: bool,
}
impl DashboardFormatter {
#[must_use]
pub fn new() -> Self {
use std::io::IsTerminal;
let use_colors = std::io::stdout().is_terminal();
Self { use_colors }
}
fn green(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[32m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn yellow(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[33m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn dim(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[2m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn bold(&self, s: &str) -> String {
if self.use_colors {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_string()
}
}
}
impl TaskListFormatter for DashboardFormatter {
fn format(&self, data: &TaskListData) -> String {
use std::fmt::Write;
let mut output = String::new();
if data.sources.is_empty() || data.stats.total_tasks == 0 {
return "No tasks defined in the configuration".to_string();
}
let mut all_tasks: Vec<DashboardTask> = Vec::new();
for group in &data.sources {
collect_tasks_for_dashboard(&group.nodes, &mut all_tasks, "");
}
let max_group = all_tasks
.iter()
.map(|t| t.group.len())
.max()
.unwrap_or(10)
.max(10);
let max_name = all_tasks
.iter()
.map(|t| t.name.len())
.max()
.unwrap_or(15)
.max(15);
let status_width = 9; let time_width = 15;
let total_width = max_group + max_name + status_width + time_width + 10;
let _ = writeln!(
output,
"โโ {} {}โโ",
self.bold("Tasks"),
"โ".repeat(total_width.saturating_sub(8))
);
let _ = writeln!(
output,
"โ {:width_g$} {:width_n$} {:width_s$} {:width_t$} โ",
self.bold("GROUP"),
self.bold("TASK"),
self.bold("STATUS"),
self.bold("LAST RUN"),
width_g = max_group,
width_n = max_name,
width_s = status_width,
width_t = time_width
);
let _ = writeln!(output, "โ {}โโค", "โ".repeat(total_width));
let mut last_group = String::new();
for task in &all_tasks {
let group_display = if task.group == last_group {
" ".repeat(max_group)
} else {
last_group.clone_from(&task.group);
format!("{:width$}", task.group, width = max_group)
};
let status_icon = if task.is_cached {
self.green("โ cached")
} else {
self.yellow("โ stale")
};
let time_display = if task.is_cached {
self.dim("recently")
} else {
self.dim("never")
};
let _ = writeln!(
output,
"โ {} {:width_n$} {} {:width_t$} โ",
group_display,
task.name,
status_icon,
time_display,
width_n = max_name,
width_t = time_width
);
}
let _ = writeln!(output, "โ{}โโ", "โ".repeat(total_width + 1));
let stale_count = data.stats.total_tasks - data.stats.cached_count;
let _ = writeln!(
output,
" {} {} cached {} {} stale {} โ {} total",
self.green("โ"),
data.stats.cached_count,
self.yellow("โ"),
stale_count,
self.dim("โ 0 running"),
data.stats.total_tasks
);
output
}
}
#[derive(Debug)]
struct DashboardTask {
group: String,
name: String,
is_cached: bool,
}
fn collect_tasks_for_dashboard(nodes: &[TaskNode], tasks: &mut Vec<DashboardTask>, prefix: &str) {
for node in nodes {
let full_name = if prefix.is_empty() {
node.name.clone()
} else {
format!("{prefix}.{}", node.name)
};
if !node.is_group {
let group = if full_name.contains('.') {
let parts: Vec<&str> = full_name.split('.').collect();
parts[0].to_string()
} else {
"root".to_string()
};
let task_name = node.name.clone();
tasks.push(DashboardTask {
group,
name: task_name,
is_cached: node.is_cached,
});
}
collect_tasks_for_dashboard(&node.children, tasks, &full_name);
}
}
#[derive(Debug, Default)]
pub struct EmojiFormatter;
impl TaskListFormatter for EmojiFormatter {
fn format(&self, data: &TaskListData) -> String {
use std::fmt::Write;
let mut output = String::new();
if data.sources.is_empty() || data.stats.total_tasks == 0 {
return "No tasks defined in the configuration".to_string();
}
let mut categorized_tasks: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
for group in &data.sources {
collect_tasks_for_emoji(&group.nodes, &mut categorized_tasks, "");
}
let max_name_width = categorized_tasks
.values()
.flatten()
.map(|(name, _)| name.len())
.max()
.unwrap_or(20)
.max(20);
for (category, tasks) in &categorized_tasks {
let emoji = get_category_emoji(category);
let _ = writeln!(output, "\n{} {}", emoji, category);
for (name, desc) in tasks {
if desc.is_empty() {
let _ = writeln!(output, " {}", name);
} else {
let _ = writeln!(
output,
" {:width$} {}",
name,
desc,
width = max_name_width
);
}
}
}
let cached_emoji = if data.stats.cached_count > 0 {
"๐ฏ"
} else {
"โช"
};
let _ = writeln!(
output,
"\n๐ฆ {} tasks โ {} {} cached โ โก Run: cuenv t <name>",
data.stats.total_tasks, cached_emoji, data.stats.cached_count
);
output
}
}
fn collect_tasks_for_emoji(
nodes: &[TaskNode],
categories: &mut BTreeMap<String, Vec<(String, String)>>,
prefix: &str,
) {
for node in nodes {
let full_name = if prefix.is_empty() {
node.name.clone()
} else {
format!("{prefix}.{}", node.name)
};
if !node.is_group {
let category = infer_category_from_name(&full_name, node.description.as_deref());
let task_full_name = node.full_name.as_deref().unwrap_or(&node.name).to_string();
let description = node.description.as_deref().unwrap_or("").to_string();
categories
.entry(category)
.or_default()
.push((task_full_name, description));
}
collect_tasks_for_emoji(&node.children, categories, &full_name);
}
}
fn infer_category_from_name(name: &str, description: Option<&str>) -> String {
let name_lower = name.to_lowercase();
let desc_lower = description.map(|d| d.to_lowercase()).unwrap_or_default();
let combined = format!("{} {}", name_lower, desc_lower);
if combined.contains("docker") || combined.contains("container") || combined.contains("image") {
return "Containers".to_string();
}
if combined.contains("security") || combined.contains("audit") || combined.contains("vuln") {
return "Security".to_string();
}
if combined.contains("publish") || combined.contains("release") || combined.contains("deploy") {
return "Release".to_string();
}
if combined.contains("test") || combined.contains("spec") || combined.contains("bench") {
return "Testing".to_string();
}
if combined.contains("lint")
|| combined.contains("fmt")
|| combined.contains("format")
|| combined.contains("check")
{
return "Code Quality".to_string();
}
if combined.contains("build") || combined.contains("compile") || combined.contains("install") {
return "Build & Compile".to_string();
}
if combined.contains("doc") || combined.contains("documentation") {
return "Documentation".to_string();
}
if combined.contains("clean") || combined.contains("reset") {
return "Maintenance".to_string();
}
if combined.contains("ci") || combined.contains("cd") {
return "CI/CD".to_string();
}
"Other".to_string()
}
fn get_category_emoji(category: &str) -> &'static str {
match category {
"Build & Compile" => "๐จ",
"Testing" => "๐งช",
"Code Quality" => "โจ",
"Release" => "๐",
"Security" => "๐",
"Containers" => "๐ณ",
"Documentation" => "๐",
"Maintenance" => "๐งน",
"CI/CD" => "โ๏ธ",
_ => "๐",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_proximity_same_dir() {
assert_eq!(source_proximity("", None), 0);
assert_eq!(source_proximity("env.cue", Some("")), 0);
}
#[test]
fn test_task_list_stats_default() {
let stats = TaskListStats::default();
assert_eq!(stats.total_tasks, 0);
assert_eq!(stats.total_groups, 0);
assert_eq!(stats.cached_count, 0);
}
#[test]
fn test_rich_formatter_no_colors() {
let formatter = RichFormatter { use_colors: false };
assert!(!formatter.use_colors);
assert_eq!(formatter.cyan("test"), "test");
}
#[test]
fn test_tables_formatter_initialization() {
let formatter = TablesFormatter::default();
assert!(!formatter.use_colors);
}
#[test]
fn test_dashboard_formatter_initialization() {
let formatter = DashboardFormatter::default();
assert!(!formatter.use_colors);
}
#[test]
fn test_emoji_formatter_can_format_empty() {
let formatter = EmojiFormatter;
let data = TaskListData {
sources: vec![],
stats: TaskListStats::default(),
};
let output = formatter.format(&data);
assert!(output.contains("No tasks"));
}
#[test]
fn test_format_category_name() {
assert_eq!(format_category_name("build"), "Build & Compile");
assert_eq!(format_category_name("test"), "Testing");
assert_eq!(format_category_name("lint"), "Code Quality");
assert_eq!(format_category_name("security"), "Security");
assert_eq!(format_category_name("cargo"), "CARGO Tasks");
}
#[test]
fn test_infer_category_from_name() {
assert_eq!(infer_category_from_name("build", None), "Build & Compile");
assert_eq!(infer_category_from_name("test.unit", None), "Testing");
assert_eq!(infer_category_from_name("lint", None), "Code Quality");
assert_eq!(infer_category_from_name("publish", None), "Release");
assert_eq!(infer_category_from_name("security.audit", None), "Security");
assert_eq!(infer_category_from_name("docker.build", None), "Containers");
assert_eq!(infer_category_from_name("container", None), "Containers");
assert_eq!(infer_category_from_name("unknown", None), "Other");
}
#[test]
fn test_get_category_emoji() {
assert_eq!(get_category_emoji("Build & Compile"), "๐จ");
assert_eq!(get_category_emoji("Testing"), "๐งช");
assert_eq!(get_category_emoji("Code Quality"), "โจ");
assert_eq!(get_category_emoji("Release"), "๐");
assert_eq!(get_category_emoji("Security"), "๐");
assert_eq!(get_category_emoji("Other"), "๐");
}
}
#[cfg(test)]
mod cache_tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_collect_cached_tasks_empty() {
let temp = TempDir::new().unwrap();
let tasks = vec![];
let cached = collect_cached_tasks(&tasks, temp.path());
assert!(cached.is_empty());
}
#[test]
fn test_group_cache_propagation() {
let mut stats = TaskListStats::default();
let mut children = BTreeMap::new();
children.insert(
"child1".to_string(),
super::TreeBuilder {
name: "child1".to_string(),
is_task: true,
is_cached: true,
..Default::default()
},
);
children.insert(
"child2".to_string(),
super::TreeBuilder {
name: "child2".to_string(),
is_task: true,
is_cached: false,
..Default::default()
},
);
let mut root_builder = BTreeMap::new();
root_builder.insert(
"group".to_string(),
super::TreeBuilder {
name: "group".to_string(),
is_task: false, is_cached: false, children,
..Default::default()
},
);
let nodes = super::convert(root_builder, &mut stats);
assert_eq!(nodes.len(), 1);
let group = &nodes[0];
assert_eq!(group.name, "group");
assert!(group.is_group);
assert!(group.is_cached);
assert_eq!(group.children.len(), 2);
let c1 = group.children.iter().find(|c| c.name == "child1").unwrap();
assert!(c1.is_cached);
let c2 = group.children.iter().find(|c| c.name == "child2").unwrap();
assert!(!c2.is_cached);
}
}