|
|
//! Ticket dependency graph computation.
|
|
|
//!
|
|
|
//! Builds a directed graph from a flat list of tickets, tracking both forward
|
|
|
//! (dependency) and reverse (dependent) edges. Used by [`crate::display`] to
|
|
|
//! render an ASCII tree and by the `graph` CLI command for JSON output.
|
|
|
//!
|
|
|
//! ## Edge semantics
|
|
|
//!
|
|
|
//! If ticket B lists ticket A in its `dependencies`, then:
|
|
|
//! - A is a **dependency** of B (A must be done before B)
|
|
|
//! - B is a **dependent** of A (B is waiting on A)
|
|
|
//!
|
|
|
//! The ASCII tree is rendered with **roots** (tickets with no in-graph
|
|
|
//! dependencies) at the top and their **dependents** indented below — showing
|
|
|
//! "what is blocked by this ticket?".
|
|
|
//!
|
|
|
//! JSON edges use `{"from": <blocker>, "to": <blocked>}` — i.e. the blocking
|
|
|
//! ticket is `from` and the waiting ticket is `to`.
|
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
|
|
use crate::ticket::{Status, Ticket};
|
|
|
|
|
|
// ── Internal helper ───────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Return the canonical display string for a [`Status`] variant.
|
|
|
fn status_str(status: &Status) -> &'static str {
|
|
|
match status {
|
|
|
Status::Todo => "todo",
|
|
|
Status::InProgress => "in_progress",
|
|
|
Status::Done => "done",
|
|
|
Status::Closed => "closed",
|
|
|
Status::Backlog => "backlog",
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Public types ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
/// A single node in the dependency graph.
|
|
|
///
|
|
|
/// Holds a reference to the source [`Ticket`] and the IDs of both its
|
|
|
/// in-graph dependencies (forward edges) and its dependents (reverse edges).
|
|
|
pub struct GraphNode<'a> {
|
|
|
/// The ticket this node represents.
|
|
|
pub ticket: &'a Ticket,
|
|
|
/// IDs of in-graph tickets that this ticket depends on (forward edges).
|
|
|
///
|
|
|
/// Only tickets present in the graph are listed; dangling references in
|
|
|
/// [`Ticket::dependencies`] are silently ignored.
|
|
|
pub dependencies: Vec<&'a str>,
|
|
|
/// IDs of tickets that list this ticket as a dependency (reverse edges).
|
|
|
///
|
|
|
/// These are the visual "children" in the ASCII dependency tree.
|
|
|
pub dependents: Vec<&'a str>,
|
|
|
}
|
|
|
|
|
|
/// A directed dependency graph built from a flat list of tickets.
|
|
|
///
|
|
|
/// Build with [`TicketGraph::build`]. Roots (entry points) are returned by
|
|
|
/// [`TicketGraph::roots`]. Use [`TicketGraph::subtree`] to extract the IDs
|
|
|
/// reachable from a specific ticket via its dependents, and
|
|
|
/// [`TicketGraph::to_json_value`] for machine-readable output.
|
|
|
pub struct TicketGraph<'a> {
|
|
|
/// Nodes keyed by ticket ID.
|
|
|
nodes: HashMap<&'a str, GraphNode<'a>>,
|
|
|
/// Ticket IDs in the order they were inserted, for stable iteration.
|
|
|
ids: Vec<&'a str>,
|
|
|
}
|
|
|
|
|
|
impl<'a> TicketGraph<'a> {
|
|
|
/// Build a graph from a slice of tickets.
|
|
|
///
|
|
|
/// Each ticket in `tickets` becomes a node. Forward edges (`dependencies`)
|
|
|
/// and reverse edges (`dependents`) are both populated. References to IDs
|
|
|
/// not present in `tickets` are silently ignored.
|
|
|
///
|
|
|
/// # Example
|
|
|
///
|
|
|
/// ```rust,ignore
|
|
|
/// let graph = TicketGraph::build(&tickets);
|
|
|
/// for root in graph.roots() {
|
|
|
/// println!("{} – {}", root.id, root.title);
|
|
|
/// }
|
|
|
/// ```
|
|
|
pub fn build(tickets: &'a [Ticket]) -> Self {
|
|
|
let mut nodes: HashMap<&'a str, GraphNode<'a>> = HashMap::with_capacity(tickets.len());
|
|
|
let mut ids: Vec<&'a str> = Vec::with_capacity(tickets.len());
|
|
|
|
|
|
// First pass: create a node for every ticket.
|
|
|
for ticket in tickets {
|
|
|
let id: &'a str = ticket.id.as_str();
|
|
|
nodes.insert(
|
|
|
id,
|
|
|
GraphNode {
|
|
|
ticket,
|
|
|
dependencies: Vec::new(),
|
|
|
dependents: Vec::new(),
|
|
|
},
|
|
|
);
|
|
|
ids.push(id);
|
|
|
}
|
|
|
|
|
|
// Second pass: collect edges (avoids simultaneous mutable borrows).
|
|
|
// Each edge is (dependent_id, dependency_id).
|
|
|
let mut edges: Vec<(&'a str, &'a str)> = Vec::new();
|
|
|
for ticket in tickets {
|
|
|
let ticket_id: &'a str = ticket.id.as_str();
|
|
|
for dep_id_owned in &ticket.dependencies {
|
|
|
let dep_id: &str = dep_id_owned.as_str();
|
|
|
// Only add an edge if the dependency exists in this graph.
|
|
|
if let Some((&stored_dep_id, _)) = nodes.get_key_value(dep_id) {
|
|
|
edges.push((ticket_id, stored_dep_id));
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// Apply collected edges to both sides of each node.
|
|
|
for (dependent_id, dependency_id) in edges {
|
|
|
if let Some(node) = nodes.get_mut(dependent_id) {
|
|
|
node.dependencies.push(dependency_id);
|
|
|
}
|
|
|
if let Some(node) = nodes.get_mut(dependency_id) {
|
|
|
node.dependents.push(dependent_id);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
TicketGraph { nodes, ids }
|
|
|
}
|
|
|
|
|
|
/// Return tickets with no in-graph dependencies, sorted by priority descending.
|
|
|
///
|
|
|
/// These are the entry points for the ASCII dependency tree renderer.
|
|
|
/// A ticket is a root when its `dependencies` list (after filtering to
|
|
|
/// in-graph tickets only) is empty.
|
|
|
pub fn roots(&self) -> Vec<&'a Ticket> {
|
|
|
let mut roots: Vec<&'a Ticket> = self
|
|
|
.ids
|
|
|
.iter()
|
|
|
.filter_map(|id| self.nodes.get(id))
|
|
|
.filter(|node| node.dependencies.is_empty())
|
|
|
.map(|node| node.ticket)
|
|
|
.collect();
|
|
|
roots.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
|
roots
|
|
|
}
|
|
|
|
|
|
/// Return the node for a ticket ID, or `None` if not present in the graph.
|
|
|
pub fn get_node(&self, id: &str) -> Option<&GraphNode<'a>> {
|
|
|
self.nodes.get(id)
|
|
|
}
|
|
|
|
|
|
/// Return all ticket IDs reachable from `root_id` via dependent edges,
|
|
|
/// in depth-first order, including `root_id` itself.
|
|
|
///
|
|
|
/// "Reachable via dependents" means: `root_id`, plus every ticket that
|
|
|
/// depends on `root_id`, plus every ticket that depends on those, and so
|
|
|
/// on. This answers "what tickets are blocked (directly or transitively)
|
|
|
/// by `root_id`?".
|
|
|
///
|
|
|
/// Cycles are handled by a visited set — each ID appears at most once.
|
|
|
/// Returns an empty `Vec` when `root_id` is not in the graph.
|
|
|
pub fn subtree(&self, root_id: &str) -> Vec<&'a str> {
|
|
|
let mut result: Vec<&'a str> = Vec::new();
|
|
|
let mut visited: HashSet<&'a str> = HashSet::new();
|
|
|
if let Some((&stored_id, _)) = self.nodes.get_key_value(root_id) {
|
|
|
dfs_subtree(&self.nodes, stored_id, &mut visited, &mut result);
|
|
|
}
|
|
|
result
|
|
|
}
|
|
|
|
|
|
/// Serialise the full graph as a JSON object with `nodes` and `edges`.
|
|
|
///
|
|
|
/// Each node includes `id`, `title`, `status`, `priority`, and
|
|
|
/// `dependencies` (only in-graph dependency IDs).
|
|
|
///
|
|
|
/// Each edge is `{"from": <blocker_id>, "to": <blocked_id>}` — meaning
|
|
|
/// the ticket identified by `from` must be completed before `to` can start.
|
|
|
pub fn to_json_value(&self) -> serde_json::Value {
|
|
|
let nodes: Vec<serde_json::Value> = self
|
|
|
.ids
|
|
|
.iter()
|
|
|
.filter_map(|id| self.nodes.get(id))
|
|
|
.map(|node| {
|
|
|
serde_json::json!({
|
|
|
"id": node.ticket.id,
|
|
|
"title": node.ticket.title,
|
|
|
"status": status_str(&node.ticket.status),
|
|
|
"priority": node.ticket.priority,
|
|
|
"dependencies": node.dependencies,
|
|
|
})
|
|
|
})
|
|
|
.collect();
|
|
|
|
|
|
// Edges point from the blocking ticket to the blocked ticket.
|
|
|
let edges: Vec<serde_json::Value> = self
|
|
|
.ids
|
|
|
.iter()
|
|
|
.filter_map(|id| self.nodes.get(id))
|
|
|
.flat_map(|node| {
|
|
|
let from = node.ticket.id.as_str();
|
|
|
node.dependents
|
|
|
.iter()
|
|
|
.map(move |&to| serde_json::json!({ "from": from, "to": to }))
|
|
|
})
|
|
|
.collect();
|
|
|
|
|
|
serde_json::json!({ "nodes": nodes, "edges": edges })
|
|
|
}
|
|
|
|
|
|
/// Serialise the subtree rooted at `root_id` as a JSON object.
|
|
|
///
|
|
|
/// Same structure as [`to_json_value`] but limited to nodes and edges
|
|
|
/// within the subtree reachable from `root_id` via dependent edges.
|
|
|
/// Returns an empty `{"nodes":[],"edges":[]}` object when `root_id` is
|
|
|
/// not in the graph.
|
|
|
pub fn to_subtree_json_value(&self, root_id: &str) -> serde_json::Value {
|
|
|
let reachable: HashSet<&'a str> = self.subtree(root_id).into_iter().collect();
|
|
|
|
|
|
let nodes: Vec<serde_json::Value> = self
|
|
|
.ids
|
|
|
.iter()
|
|
|
.filter(|&&id| reachable.contains(id))
|
|
|
.filter_map(|id| self.nodes.get(id))
|
|
|
.map(|node| {
|
|
|
let deps: Vec<&str> = node
|
|
|
.dependencies
|
|
|
.iter()
|
|
|
.copied()
|
|
|
.filter(|&d| reachable.contains(d))
|
|
|
.collect();
|
|
|
serde_json::json!({
|
|
|
"id": node.ticket.id,
|
|
|
"title": node.ticket.title,
|
|
|
"status": status_str(&node.ticket.status),
|
|
|
"priority": node.ticket.priority,
|
|
|
"dependencies": deps,
|
|
|
})
|
|
|
})
|
|
|
.collect();
|
|
|
|
|
|
let edges: Vec<serde_json::Value> = self
|
|
|
.ids
|
|
|
.iter()
|
|
|
.filter(|&&id| reachable.contains(id))
|
|
|
.filter_map(|id| self.nodes.get(id))
|
|
|
.flat_map(|node| {
|
|
|
let from = node.ticket.id.as_str();
|
|
|
node.dependents
|
|
|
.iter()
|
|
|
.copied()
|
|
|
.filter(|&to| reachable.contains(to))
|
|
|
.map(move |to| serde_json::json!({ "from": from, "to": to }))
|
|
|
})
|
|
|
.collect();
|
|
|
|
|
|
serde_json::json!({ "nodes": nodes, "edges": edges })
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────────
|
|
|
|
|
|
/// Depth-first traversal following `dependents` edges.
|
|
|
///
|
|
|
/// Visits `id` and recursively visits each of its dependents (tickets that
|
|
|
/// depend on it). A `visited` set prevents revisiting nodes, making the
|
|
|
/// function safe even when the data contains dependency cycles.
|
|
|
fn dfs_subtree<'a>(
|
|
|
nodes: &HashMap<&'a str, GraphNode<'a>>,
|
|
|
id: &'a str,
|
|
|
visited: &mut HashSet<&'a str>,
|
|
|
result: &mut Vec<&'a str>,
|
|
|
) {
|
|
|
if !visited.insert(id) {
|
|
|
return;
|
|
|
}
|
|
|
result.push(id);
|
|
|
if let Some(node) = nodes.get(id) {
|
|
|
// Clone the list to avoid holding an immutable borrow while we recurse.
|
|
|
let dependents: Vec<&'a str> = node.dependents.clone();
|
|
|
for dep_id in dependents {
|
|
|
dfs_subtree(nodes, dep_id, visited, result);
|
|
|
}
|
|
|
}
|
|
|
}
|