//! 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": , "to": }` — 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": , "to": }` — 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 = 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 = 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 = 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 = 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); } } }