use super::display::format_job_cell;
use std::collections::{HashMap, HashSet};
use tabled::{builder::Builder, settings::style::Style};
const TREE_BRANCH: &str = "├─";
const TREE_EDGE: &str = "╰─";
const TREE_PIPE: &str = "│ ";
const TREE_EMPTY: &str = " ";
const TREE_BRANCH_DASHED: &str = "├┄";
const TREE_EDGE_DASHED: &str = "╰┄";
pub(super) struct JobNode {
pub(super) job: gflow::core::job::Job,
pub(super) children: Vec<JobNodeChild>,
}
pub(super) enum JobNodeChild {
Node(Box<JobNode>, bool), Reference(u32), }
struct RenderContext<'a> {
headers: &'a [&'a str],
tmux_sessions: &'a HashSet<String>,
}
pub(super) fn build_dependency_tree(jobs: &[gflow::core::job::Job]) -> Vec<JobNode> {
let job_map: HashMap<u32, &gflow::core::job::Job> = jobs.iter().map(|j| (j.id, j)).collect();
let mut children_map: HashMap<Option<u32>, Vec<u32>> = HashMap::new();
let mut redo_map: HashMap<u32, Vec<u32>> = HashMap::new();
let mut all_dependency_children: HashSet<u32> = HashSet::new();
for job in jobs {
children_map.entry(job.depends_on).or_default().push(job.id);
if let Some(parent_id) = job.depends_on {
if job_map.contains_key(&parent_id) {
all_dependency_children.insert(job.id);
}
}
if let Some(redone_from) = job.redone_from {
redo_map.entry(redone_from).or_default().push(job.id);
}
}
fn build_node(
job_id: u32,
job_map: &HashMap<u32, &gflow::core::job::Job>,
children_map: &HashMap<Option<u32>, Vec<u32>>,
redo_map: &HashMap<u32, Vec<u32>>,
all_dependency_children: &HashSet<u32>,
visited: &mut HashSet<u32>,
recursion_stack: &mut HashSet<u32>,
) -> Option<JobNode> {
if recursion_stack.contains(&job_id) {
tracing::warn!(
"Circular dependency detected for job {}, skipping subtree",
job_id
);
return None;
}
let job = (*job_map.get(&job_id)?).clone();
visited.insert(job_id);
recursion_stack.insert(job_id);
let dep_child_ids: HashSet<u32> = children_map
.get(&Some(job_id))
.into_iter()
.flatten()
.copied()
.collect();
let dep_iter = dep_child_ids.iter().map(|&id| (id, false));
let redo_iter = redo_map
.get(&job_id)
.into_iter()
.flatten()
.map(|&id| (id, true));
let mut children: Vec<JobNodeChild> = dep_iter
.chain(redo_iter)
.filter_map(|(child_id, is_redo)| {
if is_redo && all_dependency_children.contains(&child_id) {
Some(JobNodeChild::Reference(child_id))
} else {
build_node(
child_id,
job_map,
children_map,
redo_map,
all_dependency_children,
visited,
recursion_stack,
)
.map(|child_node| JobNodeChild::Node(Box::new(child_node), is_redo))
}
})
.collect();
children.sort_by_key(|child| match child {
JobNodeChild::Node(node, _) => node.job.id,
JobNodeChild::Reference(id) => *id,
});
recursion_stack.remove(&job_id);
Some(JobNode { job, children })
}
let mut root_ids: Vec<u32> = jobs
.iter()
.filter_map(|job| match job.depends_on {
None => Some(job.id),
Some(parent_id) if !job_map.contains_key(&parent_id) => Some(job.id),
_ => None,
})
.collect();
root_ids.sort_unstable();
root_ids.dedup();
root_ids.retain(|job_id| {
let parent_exists = job_map
.get(job_id)
.and_then(|job| job.redone_from)
.is_some_and(|parent_id| job_map.contains_key(&parent_id));
!parent_exists
});
let mut visited = HashSet::new();
let mut recursion_stack = HashSet::new();
root_ids
.into_iter()
.filter_map(|job_id| {
build_node(
job_id,
&job_map,
&children_map,
&redo_map,
&all_dependency_children,
&mut visited,
&mut recursion_stack,
)
})
.collect()
}
pub(super) fn display_jobs_tree(
jobs: &[gflow::core::job::Job],
format: Option<&str>,
tmux_sessions: &HashSet<String>,
) {
if jobs.is_empty() {
println!("No jobs to display.");
return;
}
let format = format
.unwrap_or("JOBID,NAME,ST,TIME,NODES,NODELIST(REASON)")
.to_string();
let headers: Vec<&str> = format.split(',').collect();
let tree = build_dependency_tree(jobs);
let mut builder = Builder::default();
builder.push_record(headers.clone());
let ctx = RenderContext {
headers: &headers,
tmux_sessions,
};
for node in &tree {
collect_tree_rows(&mut builder, node, &ctx, "", true, true, false);
}
let mut table = builder.build();
table.with(Style::blank());
println!("{}", table);
}
fn collect_tree_rows(
builder: &mut Builder,
node: &JobNode,
ctx: &RenderContext,
prefix: &str,
is_last: bool,
is_root: bool,
is_redo: bool,
) {
let job = &node.job;
let tree_prefix = if is_root {
String::new()
} else if is_redo {
if is_last {
TREE_EDGE_DASHED.to_string()
} else {
TREE_BRANCH_DASHED.to_string()
}
} else {
if is_last {
TREE_EDGE.to_string()
} else {
TREE_BRANCH.to_string()
}
};
let row: Vec<String> = ctx
.headers
.iter()
.enumerate()
.map(|(idx, header)| {
if *header == "JOBID" && idx == 0 {
format!("{}{}{}", prefix, tree_prefix, job.id)
} else {
format_job_cell(job, header, ctx.tmux_sessions)
}
})
.collect();
builder.push_record(row);
let child_count = node.children.len();
for (idx, child) in node.children.iter().enumerate() {
let is_last_child = idx == child_count - 1;
let child_prefix = if is_root {
String::new()
} else {
if is_last {
format!("{}{}", prefix, TREE_EMPTY)
} else {
format!("{}{}", prefix, TREE_PIPE)
}
};
match child {
JobNodeChild::Node(child_node, child_is_redo) => {
collect_tree_rows(
builder,
child_node,
ctx,
&child_prefix,
is_last_child,
false,
*child_is_redo,
);
}
JobNodeChild::Reference(job_id) => {
let tree_prefix = if is_last_child {
TREE_EDGE_DASHED
} else {
TREE_BRANCH_DASHED
};
let reference_text = format!("{}{}→ see job {}", child_prefix, tree_prefix, job_id);
let row: Vec<String> = ctx
.headers
.iter()
.enumerate()
.map(|(idx, header)| {
if *header == "JOBID" && idx == 0 {
reference_text.clone()
} else {
"-".to_string()
}
})
.collect();
builder.push_record(row);
}
}
}
}