You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

285 lines
11 KiB
Rust

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! 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);
}
}
}