use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::task_graph_knowledge::{KnowledgeNode, KnowledgeGraph, KnowledgeManagement};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
#[serde(default)]
pub project: Option<ProjectMeta>,
#[serde(default)]
pub nodes: Vec<Node>,
#[serde(default)]
pub edges: Vec<Edge>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProjectMeta {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
impl<'de> serde::Deserialize<'de> for ProjectMeta {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de;
struct ProjectMetaVisitor;
impl<'de> de::Visitor<'de> for ProjectMetaVisitor {
type Value = ProjectMeta;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or a map with 'name' field")
}
fn visit_str<E>(self, v: &str) -> std::result::Result<ProjectMeta, E>
where
E: de::Error,
{
Ok(ProjectMeta { name: v.to_string(), description: None })
}
fn visit_map<M>(self, map: M) -> std::result::Result<ProjectMeta, M::Error>
where
M: de::MapAccess<'de>,
{
#[derive(serde::Deserialize)]
struct ProjectMetaInner {
name: String,
#[serde(default)]
description: Option<String>,
}
let inner = ProjectMetaInner::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(ProjectMeta { name: inner.name, description: inner.description })
}
}
deserializer.deserialize_any(ProjectMetaVisitor)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub id: String,
pub title: String,
#[serde(default)]
pub status: NodeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assigned_to: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<u8>,
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
pub node_type: Option<String>,
#[serde(default, skip_serializing_if = "KnowledgeNode::is_empty")]
pub knowledge: KnowledgeNode,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_line: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_line: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub doc_comment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub depth: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub complexity: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_public: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
}
impl Node {
pub fn new(id: &str, title: &str) -> Self {
Self {
id: id.to_string(),
title: title.to_string(),
status: NodeStatus::Todo,
description: None,
assigned_to: None,
tags: Vec::new(),
priority: None,
node_type: None,
knowledge: KnowledgeNode::default(),
metadata: HashMap::new(),
file_path: None,
lang: None,
start_line: None,
end_line: None,
signature: None,
visibility: None,
doc_comment: None,
body_hash: None,
node_kind: None,
owner: None,
source: None,
repo: None,
parent_id: None,
depth: None,
complexity: None,
is_public: None,
body: None,
created_at: None,
updated_at: None,
}
}
pub fn with_description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
pub fn with_status(mut self, status: NodeStatus) -> Self {
self.status = status;
self
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = Some(priority);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NodeStatus {
Todo,
#[serde(alias = "in_progress", alias = "in-progress")]
InProgress,
Done,
Blocked,
Cancelled,
Failed,
#[serde(alias = "needs_resolution", alias = "needs-resolution")]
NeedsResolution,
}
impl Default for NodeStatus {
fn default() -> Self {
Self::Todo
}
}
impl std::fmt::Display for NodeStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NodeStatus::Todo => write!(f, "todo"),
NodeStatus::InProgress => write!(f, "in_progress"),
NodeStatus::Done => write!(f, "done"),
NodeStatus::Blocked => write!(f, "blocked"),
NodeStatus::Cancelled => write!(f, "cancelled"),
NodeStatus::Failed => write!(f, "failed"),
NodeStatus::NeedsResolution => write!(f, "needs_resolution"),
}
}
}
impl std::str::FromStr for NodeStatus {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"todo" => Ok(NodeStatus::Todo),
"in_progress" | "in-progress" => Ok(NodeStatus::InProgress),
"done" => Ok(NodeStatus::Done),
"blocked" => Ok(NodeStatus::Blocked),
"cancelled" => Ok(NodeStatus::Cancelled),
"failed" => Ok(NodeStatus::Failed),
"needs_resolution" | "needs-resolution" => Ok(NodeStatus::NeedsResolution),
_ => Err(anyhow::anyhow!("Unknown status: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Edge {
pub from: String,
pub to: String,
#[serde(default = "default_relation")]
pub relation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
fn default_relation() -> String {
"depends_on".to_string()
}
impl Edge {
pub fn new(from: &str, to: &str, relation: &str) -> Self {
Self {
from: from.to_string(),
to: to.to_string(),
relation: relation.to_string(),
weight: None,
confidence: None,
metadata: None,
}
}
pub fn depends_on(from: &str, to: &str) -> Self {
Self::new(from, to, "depends_on")
}
pub fn source(&self) -> Option<&str> {
self.metadata.as_ref()
.and_then(|m| m.get("source"))
.and_then(|v| v.as_str())
}
}
#[derive(Debug, Clone)]
pub struct TaskSpec {
pub title: String,
pub status: Option<NodeStatus>, pub tags: Vec<String>, pub deps: Vec<String>, }
pub fn infer_node_type(id: &str) -> Option<&str> {
let prefix = id.split(':').next()?;
match prefix {
"file" => Some("file"),
"fn" | "func" => Some("function"),
"struct" | "class" => Some("class"),
"mod" | "module" => Some("module"),
"method" => Some("method"),
"trait" | "interface" => Some("trait"),
"enum" => Some("enum"),
"const" | "static" => Some("constant"),
"test" => Some("test"),
"impl" => Some("impl"),
_ => None,
}
}
impl Graph {
pub fn new() -> Self {
Self::default()
}
pub fn get_node(&self, id: &str) -> Option<&Node> {
self.nodes.iter().find(|n| n.id == id)
}
pub fn get_node_mut(&mut self, id: &str) -> Option<&mut Node> {
self.nodes.iter_mut().find(|n| n.id == id)
}
pub fn add_node(&mut self, node: Node) {
if self.get_node(&node.id).is_none() {
let mut node = node;
if node.node_type.is_none() {
if let Some(inferred) = infer_node_type(&node.id) {
node.node_type = Some(inferred.to_string());
}
}
self.nodes.push(node);
}
}
pub fn remove_node(&mut self, id: &str) -> Option<Node> {
let pos = self.nodes.iter().position(|n| n.id == id)?;
let node = self.nodes.remove(pos);
self.edges.retain(|e| e.from != id && e.to != id);
Some(node)
}
pub fn update_status(&mut self, id: &str, status: NodeStatus) -> bool {
if let Some(node) = self.get_node_mut(id) {
node.status = status;
true
} else {
false
}
}
pub fn add_edge(&mut self, edge: Edge) {
let exists = self.edges.iter().any(|e| {
e.from == edge.from && e.to == edge.to && e.relation == edge.relation
});
if !exists {
self.edges.push(edge);
}
}
pub fn remove_edge(&mut self, from: &str, to: &str, relation: Option<&str>) {
self.edges.retain(|e| {
!(e.from == from && e.to == to && relation.map_or(true, |r| e.relation == r))
});
}
pub fn add_edge_dedup(&mut self, edge: Edge) -> bool {
let exists = self.edges.iter().any(|e| {
e.from == edge.from && e.to == edge.to && e.relation == edge.relation
});
if !exists {
self.edges.push(edge);
true
} else {
false
}
}
fn ensure_unique_id(&self, base: String) -> String {
if self.get_node(&base).is_none() {
return base;
}
for i in 2..1000 {
let candidate = format!("{}-{}", base, i);
if self.get_node(&candidate).is_none() {
return candidate;
}
}
format!("{}-overflow", base)
}
pub fn add_feature(&mut self, name: &str, tasks: &[TaskSpec]) -> String {
use crate::slugify::slugify;
let feature_slug = slugify(name);
let feat_id = self.ensure_unique_id(format!("feat-{}", feature_slug));
let mut feat = Node::new(&feat_id, name);
feat.node_type = Some("feature".into());
feat.status = NodeStatus::Todo;
self.add_node(feat);
let mut task_ids: HashMap<String, String> = HashMap::new();
for spec in tasks {
let task_slug = slugify(&spec.title);
let base_id = format!("task-{}-{}", feature_slug, task_slug);
let task_id = self.ensure_unique_id(base_id);
let mut task = Node::new(&task_id, &spec.title);
task.node_type = Some("task".into());
task.status = spec.status.clone().unwrap_or(NodeStatus::Todo);
task.tags = spec.tags.clone();
self.add_node(task);
self.add_edge_dedup(Edge::new(&task_id, &feat_id, "implements"));
task_ids.insert(spec.title.clone(), task_id);
}
for spec in tasks {
if let Some(from_id) = task_ids.get(&spec.title) {
for dep_title in &spec.deps {
if let Some(to_id) = task_ids.get(dep_title.as_str()) {
self.add_edge_dedup(Edge::new(from_id, to_id, "depends_on"));
}
}
}
}
feat_id
}
pub fn add_task(
&mut self,
title: &str,
for_feature: Option<&str>,
depends_on: &[String],
tags: &[String],
priority: Option<u8>,
) -> String {
use crate::slugify::slugify;
let task_slug = slugify(title);
let base_id = if let Some(feat_id) = for_feature {
let feat_slug = feat_id.strip_prefix("feat-").unwrap_or(feat_id);
format!("task-{}-{}", feat_slug, task_slug)
} else {
format!("task-{}", task_slug)
};
let task_id = self.ensure_unique_id(base_id);
let mut task = Node::new(&task_id, title);
task.node_type = Some("task".into());
task.status = NodeStatus::Todo;
task.tags = tags.to_vec();
task.priority = priority;
self.add_node(task);
if let Some(feat_id) = for_feature {
self.add_edge_dedup(Edge::new(&task_id, feat_id, "implements"));
}
for dep in depends_on {
let resolved = self.resolve_node(dep);
if let Some(dep_node) = resolved.first() {
let dep_id = dep_node.id.clone();
self.add_edge_dedup(Edge::new(&task_id, &dep_id, "depends_on"));
} else {
eprintln!("⚠ Could not resolve dependency: {}", dep);
}
}
task_id
}
pub fn merge_feature_nodes(&mut self, feature_id: &str, incoming: Graph) -> (usize, usize) {
let old_task_ids: Vec<String> = self.edges.iter()
.filter(|e| e.to == feature_id && e.relation == "implements")
.map(|e| e.from.clone())
.collect();
let removed = old_task_ids.len();
for id in &old_task_ids {
self.remove_node(id);
}
let incoming_node_ids: std::collections::HashSet<String> = incoming.nodes.iter()
.map(|n| n.id.clone())
.collect();
let added = incoming.nodes.len();
for node in incoming.nodes {
self.add_node(node);
}
for id in &incoming_node_ids {
self.add_edge_dedup(Edge::new(id, feature_id, "implements"));
}
for edge in incoming.edges {
self.add_edge_dedup(edge);
}
(removed, added)
}
pub fn resolve_node(&self, reference: &str) -> Vec<&Node> {
let reference_lower = reference.to_lowercase();
if let Some(node) = self.nodes.iter().find(|n| n.id == reference) {
return vec![node];
}
let exact_title: Vec<&Node> = self.nodes.iter()
.filter(|n| n.title.to_lowercase() == reference_lower)
.collect();
if !exact_title.is_empty() {
return exact_title;
}
let structural_segments = extract_segments(&reference_lower, &[':', '-', '/']);
if !structural_segments.is_empty() {
let matches: Vec<&Node> = self.nodes.iter()
.filter(|n| {
let id_segs = extract_segments(&n.id.to_lowercase(), &[':', '-', '/']);
let title_segs = extract_segments(&n.title.to_lowercase(), &[':', '-', '/']);
segments_match(&structural_segments, &id_segs) ||
segments_match(&structural_segments, &title_segs)
})
.collect();
if !matches.is_empty() {
return matches;
}
}
let word_segments = extract_segments(&reference_lower, &['_']);
if !word_segments.is_empty() {
let matches: Vec<&Node> = self.nodes.iter()
.filter(|n| {
let id_segs = extract_segments(&n.id.to_lowercase(), &['_']);
let title_segs = extract_segments(&n.title.to_lowercase(), &['_']);
segments_match(&word_segments, &id_segs) ||
segments_match(&word_segments, &title_segs)
})
.collect();
if !matches.is_empty() {
return matches;
}
}
let matches: Vec<&Node> = self.nodes.iter()
.filter(|n| {
n.file_path.as_ref()
.map(|fp| fp.to_lowercase().contains(&reference_lower))
.unwrap_or(false)
})
.collect();
if !matches.is_empty() {
return matches;
}
let matches: Vec<&Node> = self.nodes.iter()
.filter(|n| n.title.to_lowercase().contains(&reference_lower))
.collect();
if !matches.is_empty() {
return matches;
}
let matches: Vec<&Node> = self.nodes.iter()
.filter(|n| n.id.to_lowercase().contains(&reference_lower))
.collect();
matches
}
pub fn edges_from(&self, id: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.from == id).collect()
}
pub fn edges_to(&self, id: &str) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.to == id).collect()
}
pub fn code_nodes(&self) -> Vec<&Node> {
self.nodes.iter().filter(|n| n.source.as_deref() == Some("extract")).collect()
}
pub fn project_nodes(&self) -> Vec<&Node> {
self.nodes.iter().filter(|n| {
n.source.as_deref().map_or(true, |s| s == "project")
}).collect()
}
pub fn code_edges(&self) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.source() == Some("extract")).collect()
}
pub fn project_edges(&self) -> Vec<&Edge> {
self.edges.iter().filter(|e| {
let src = e.source();
src != Some("extract") && src != Some("auto-bridge")
}).collect()
}
pub fn bridge_edges(&self) -> Vec<&Edge> {
self.edges.iter().filter(|e| e.source() == Some("auto-bridge")).collect()
}
pub fn ready_tasks(&self) -> Vec<&Node> {
let status_map: HashMap<&str, &NodeStatus> = self
.nodes
.iter()
.map(|n| (n.id.as_str(), &n.status))
.collect();
let mut dep_edges: HashMap<&str, Vec<&Edge>> = HashMap::new();
for e in &self.edges {
if e.relation == "depends_on" {
dep_edges.entry(e.from.as_str()).or_default().push(e);
}
}
self.project_nodes()
.into_iter()
.filter(|n| n.status == NodeStatus::Todo)
.filter(|n| {
match dep_edges.get(n.id.as_str()) {
None => true, Some(deps) => deps.iter().all(|e| {
status_map
.get(e.to.as_str())
.map_or(true, |s| **s == NodeStatus::Done)
}),
}
})
.collect()
}
pub fn tasks_by_status(&self, status: &NodeStatus) -> Vec<&Node> {
self.nodes.iter().filter(|n| &n.status == status).collect()
}
pub fn summary(&self) -> GraphSummary {
let project_nodes = self.project_nodes();
let mut s = GraphSummary {
total_nodes: project_nodes.len(),
total_edges: self.edges.len(),
..Default::default()
};
for n in &project_nodes {
match n.status {
NodeStatus::Todo => s.todo += 1,
NodeStatus::InProgress => s.in_progress += 1,
NodeStatus::Done => s.done += 1,
NodeStatus::Blocked => s.blocked += 1,
NodeStatus::Cancelled => s.cancelled += 1,
NodeStatus::Failed => s.failed += 1,
NodeStatus::NeedsResolution => s.needs_resolution += 1,
}
}
s.ready = self.ready_tasks().len();
s
}
pub fn summary_text(&self) -> String {
let s = self.summary();
let mut lines = vec![
format!("Graph: {} nodes, {} edges", s.total_nodes, s.total_edges),
];
if s.total_nodes > 0 {
lines.push(format!(
"Status: {} todo, {} in-progress, {} done, {} blocked, {} cancelled",
s.todo, s.in_progress, s.done, s.blocked, s.cancelled
));
lines.push(format!("Ready tasks: {}", s.ready));
}
if let Some(ref project) = self.project {
lines.insert(0, format!("Project: {}", project.name));
}
lines.join("\n")
}
pub fn health(&self) -> f64 {
if self.nodes.is_empty() {
return 0.0;
}
let s = self.summary();
let total = s.total_nodes as f64;
let progress = s.done as f64 / total;
let remaining = s.todo + s.in_progress;
let flow = if remaining == 0 {
1.0 } else if s.ready == 0 && s.todo > 0 {
0.0 } else {
(s.ready as f64) / (remaining as f64)
};
let connectivity = if self.nodes.len() > 1 {
let max_edges = self.nodes.len() * (self.nodes.len() - 1);
let actual = self.edges.len().min(max_edges);
(actual as f64 / max_edges as f64).min(1.0)
} else {
1.0 };
let blocked_ratio = s.blocked as f64 / total;
let blocked_penalty = 1.0 - blocked_ratio;
let health = 0.4 * progress + 0.3 * flow + 0.1 * connectivity + 0.2 * blocked_penalty;
health.clamp(0.0, 1.0)
}
pub fn mark_task_done(&mut self, node_id: &str) -> bool {
self.update_status(node_id, NodeStatus::Done)
}
pub fn get_executable_tasks(&self) -> Vec<Task> {
self.ready_tasks()
.into_iter()
.map(|node| Task {
id: node.id.clone(),
title: node.title.clone(),
description: node.description.clone(),
priority: node.priority,
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct Task {
pub id: String,
pub title: String,
pub description: Option<String>,
pub priority: Option<u8>,
}
#[derive(Debug, Default)]
pub struct GraphSummary {
pub total_nodes: usize,
pub total_edges: usize,
pub todo: usize,
pub in_progress: usize,
pub done: usize,
pub blocked: usize,
pub cancelled: usize,
pub failed: usize,
pub needs_resolution: usize,
pub ready: usize,
}
impl KnowledgeGraph for Graph {
fn get_knowledge_mut(&mut self, node_id: &str) -> Option<&mut KnowledgeNode> {
self.nodes.iter_mut()
.find(|n| n.id == node_id)
.map(|n| &mut n.knowledge)
}
fn get_knowledge(&self, node_id: &str) -> Option<&KnowledgeNode> {
self.nodes.iter()
.find(|n| n.id == node_id)
.map(|n| &n.knowledge)
}
fn get_incoming_edges(&self, node_id: &str) -> Vec<String> {
self.edges.iter()
.filter(|e| e.to == node_id)
.map(|e| e.from.clone())
.collect()
}
}
impl KnowledgeManagement for Graph {}
impl std::fmt::Display for GraphSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} nodes, {} edges | todo={} progress={} done={} blocked={} failed={} cancelled={} | ready={}",
self.total_nodes, self.total_edges,
self.todo, self.in_progress, self.done, self.blocked, self.failed, self.cancelled,
self.ready,
)
}
}
fn extract_segments(text: &str, delimiters: &[char]) -> Vec<String> {
let mut segments = vec![text.to_string()];
for &delimiter in delimiters {
let mut new_segments = Vec::new();
for segment in segments {
new_segments.extend(
segment.split(delimiter)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
);
}
segments = new_segments;
}
segments
}
fn segments_match(query_segments: &[String], target_segments: &[String]) -> bool {
if query_segments.is_empty() {
return false;
}
query_segments.iter().all(|q| target_segments.contains(q))
}
#[cfg(test)]
mod layer_filter_tests {
use super::*;
fn mixed_graph() -> Graph {
let mut g = Graph::new();
let mut task = Node::new("task-1", "My Task");
task.source = Some("project".to_string());
g.add_node(task);
let legacy = Node::new("legacy-1", "Legacy Task");
g.add_node(legacy);
let mut code = Node::new("fn:main", "main function");
code.source = Some("extract".to_string());
code.node_type = Some("code".to_string());
code.status = NodeStatus::Done;
g.add_node(code);
let mut code2 = Node::new("struct:Config", "Config struct");
code2.source = Some("extract".to_string());
code2.node_type = Some("code".to_string());
code2.status = NodeStatus::Done;
g.add_node(code2);
g.add_edge(Edge::new("task-1", "legacy-1", "depends_on"));
let mut code_edge = Edge::new("fn:main", "struct:Config", "calls");
code_edge.metadata = Some(serde_json::json!({"source": "extract"}));
g.add_edge(code_edge);
let mut bridge = Edge::new("task-1", "fn:main", "maps_to");
bridge.metadata = Some(serde_json::json!({"source": "auto-bridge"}));
g.add_edge(bridge);
g
}
#[test]
fn test_edge_source() {
let g = mixed_graph();
let proj_edge = g.edges.iter().find(|e| e.relation == "depends_on").unwrap();
assert_eq!(proj_edge.source(), None);
let code_edge = g.edges.iter().find(|e| e.relation == "calls").unwrap();
assert_eq!(code_edge.source(), Some("extract"));
let bridge_edge = g.edges.iter().find(|e| e.relation == "maps_to").unwrap();
assert_eq!(bridge_edge.source(), Some("auto-bridge"));
}
#[test]
fn test_code_nodes() {
let g = mixed_graph();
let cn = g.code_nodes();
assert_eq!(cn.len(), 2);
assert!(cn.iter().all(|n| n.source.as_deref() == Some("extract")));
}
#[test]
fn test_project_nodes() {
let g = mixed_graph();
let pn = g.project_nodes();
assert_eq!(pn.len(), 2); assert!(pn.iter().any(|n| n.id == "task-1"));
assert!(pn.iter().any(|n| n.id == "legacy-1"));
}
#[test]
fn test_code_edges() {
let g = mixed_graph();
assert_eq!(g.code_edges().len(), 1);
}
#[test]
fn test_project_edges() {
let g = mixed_graph();
assert_eq!(g.project_edges().len(), 1); }
#[test]
fn test_bridge_edges() {
let g = mixed_graph();
assert_eq!(g.bridge_edges().len(), 1);
}
#[test]
fn test_summary_excludes_code_nodes() {
let g = mixed_graph();
let s = g.summary();
assert_eq!(s.total_nodes, 2);
}
#[test]
fn test_ready_tasks_excludes_code_nodes() {
let mut g = mixed_graph();
g.update_status("legacy-1", NodeStatus::Done);
let ready = g.ready_tasks();
assert!(ready.iter().any(|n| n.id == "task-1"));
assert!(!ready.iter().any(|n| n.source.as_deref() == Some("extract")));
}
}
#[cfg(test)]
mod add_edge_dedup_tests {
use super::*;
#[test]
fn test_new_edge_returns_true() {
let mut g = Graph::new();
g.add_node(Node::new("a", "A"));
g.add_node(Node::new("b", "B"));
let result = g.add_edge_dedup(Edge::new("a", "b", "depends_on"));
assert!(result);
assert_eq!(g.edges.len(), 1);
}
#[test]
fn test_duplicate_returns_false() {
let mut g = Graph::new();
g.add_node(Node::new("a", "A"));
g.add_node(Node::new("b", "B"));
g.add_edge_dedup(Edge::new("a", "b", "depends_on"));
let result = g.add_edge_dedup(Edge::new("a", "b", "depends_on"));
assert!(!result);
assert_eq!(g.edges.len(), 1);
}
#[test]
fn test_same_from_to_different_relation() {
let mut g = Graph::new();
g.add_node(Node::new("a", "A"));
g.add_node(Node::new("b", "B"));
assert!(g.add_edge_dedup(Edge::new("a", "b", "depends_on")));
assert!(g.add_edge_dedup(Edge::new("a", "b", "blocks")));
assert_eq!(g.edges.len(), 2);
}
#[test]
fn test_same_from_relation_different_to() {
let mut g = Graph::new();
g.add_node(Node::new("a", "A"));
g.add_node(Node::new("b", "B"));
g.add_node(Node::new("c", "C"));
assert!(g.add_edge_dedup(Edge::new("a", "b", "depends_on")));
assert!(g.add_edge_dedup(Edge::new("a", "c", "depends_on")));
assert_eq!(g.edges.len(), 2);
}
}
#[cfg(test)]
mod resolve_node_tests {
use super::*;
fn test_graph() -> Graph {
let mut g = Graph::new();
g.add_node(Node::new("task-auth", "Auth Module"));
g.add_node(Node::new("feat:auth:login", "Login Feature"));
g.add_node(Node::new("validate_auth_token", "Token Validator"));
g.add_node(Node::new("file:src/main.rs", "Main Entry"));
g.add_node(Node::new("impl-auth-middleware", "User Login Flow"));
g.add_node(Node::new("task-db", "Database Setup"));
g
}
#[test]
fn test_exact_id_match() {
let g = test_graph();
let results = g.resolve_node("task-auth");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "task-auth");
}
#[test]
fn test_exact_title_match_case_insensitive() {
let g = test_graph();
let results = g.resolve_node("auth module");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "task-auth");
}
#[test]
fn test_structural_segment_colon() {
let g = test_graph();
let results = g.resolve_node("login");
assert!(results.iter().any(|n| n.id == "feat:auth:login"));
}
#[test]
fn test_word_segment_underscore() {
let g = test_graph();
let results = g.resolve_node("validate");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "validate_auth_token");
}
#[test]
fn test_file_path_match() {
let g = test_graph();
let results = g.resolve_node("main.rs");
assert!(results.iter().any(|n| n.id == "file:src/main.rs"));
}
#[test]
fn test_title_substring() {
let g = test_graph();
let results = g.resolve_node("Login Flow");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "impl-auth-middleware");
}
#[test]
fn test_id_substring() {
let g = test_graph();
let results = g.resolve_node("middleware");
assert!(results.iter().any(|n| n.id == "impl-auth-middleware"));
}
#[test]
fn test_zero_matches() {
let g = test_graph();
let results = g.resolve_node("nonexistent_xyz");
assert!(results.is_empty());
}
#[test]
fn test_tier_priority() {
let mut g = Graph::new();
g.add_node(Node::new("auth", "Something"));
g.add_node(Node::new("other", "auth related"));
let results = g.resolve_node("auth");
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "auth");
}
#[test]
fn test_multiple_matches_same_tier() {
let mut g = Graph::new();
g.add_node(Node::new("node-1", "Auth Login"));
g.add_node(Node::new("node-2", "Auth Signup"));
let results = g.resolve_node("auth");
assert_eq!(results.len(), 2);
}
}
#[cfg(test)]
mod add_feature_tests {
use super::*;
#[test]
fn test_basic_feature_with_tasks() {
let mut g = Graph::new();
let tasks = vec![
TaskSpec { title: "Design API".into(), status: None, tags: vec![], deps: vec![] },
TaskSpec { title: "Write Tests".into(), status: None, tags: vec![], deps: vec![] },
];
let feat_id = g.add_feature("User Auth", &tasks);
assert_eq!(feat_id, "feat-user-auth");
let feat = g.get_node("feat-user-auth").unwrap();
assert_eq!(feat.title, "User Auth");
assert_eq!(feat.node_type.as_deref(), Some("feature"));
assert_eq!(feat.status, NodeStatus::Todo);
assert!(g.get_node("task-user-auth-design-api").is_some());
assert!(g.get_node("task-user-auth-write-tests").is_some());
let implements: Vec<_> = g.edges.iter()
.filter(|e| e.relation == "implements" && e.to == "feat-user-auth")
.collect();
assert_eq!(implements.len(), 2);
}
#[test]
fn test_feature_with_deps() {
let mut g = Graph::new();
let tasks = vec![
TaskSpec { title: "Setup DB".into(), status: None, tags: vec![], deps: vec![] },
TaskSpec { title: "Write Schema".into(), status: None, tags: vec![], deps: vec!["Setup DB".into()] },
TaskSpec { title: "Add Migrations".into(), status: None, tags: vec![], deps: vec!["Write Schema".into()] },
];
let feat_id = g.add_feature("Database", &tasks);
assert_eq!(feat_id, "feat-database");
let deps: Vec<_> = g.edges.iter()
.filter(|e| e.relation == "depends_on")
.collect();
assert_eq!(deps.len(), 2);
assert!(g.edges.iter().any(|e| {
e.from == "task-database-write-schema" && e.to == "task-database-setup-db" && e.relation == "depends_on"
}));
assert!(g.edges.iter().any(|e| {
e.from == "task-database-add-migrations" && e.to == "task-database-write-schema" && e.relation == "depends_on"
}));
}
#[test]
fn test_feature_id_collision() {
let mut g = Graph::new();
let tasks = vec![
TaskSpec { title: "Task A".into(), status: None, tags: vec![], deps: vec![] },
];
let id1 = g.add_feature("Auth", &tasks);
assert_eq!(id1, "feat-auth");
let id2 = g.add_feature("Auth", &[]);
assert_eq!(id2, "feat-auth-2");
assert!(g.get_node("feat-auth").is_some());
assert!(g.get_node("feat-auth-2").is_some());
}
}
#[cfg(test)]
mod add_task_tests {
use super::*;
#[test]
fn test_standalone_task() {
let mut g = Graph::new();
let task_id = g.add_task("Fix login bug", None, &[], &[], None);
assert_eq!(task_id, "task-fix-login-bug");
let node = g.get_node(&task_id).unwrap();
assert_eq!(node.title, "Fix login bug");
assert_eq!(node.node_type.as_deref(), Some("task"));
assert_eq!(node.status, NodeStatus::Todo);
assert!(g.edges.is_empty());
}
#[test]
fn test_task_with_feature() {
let mut g = Graph::new();
g.add_feature("Auth", &[]);
let task_id = g.add_task("Add OAuth", Some("feat-auth"), &[], &["backend".into()], Some(1));
assert_eq!(task_id, "task-auth-add-oauth");
let node = g.get_node(&task_id).unwrap();
assert_eq!(node.tags, vec!["backend".to_string()]);
assert_eq!(node.priority, Some(1));
assert!(g.edges.iter().any(|e| {
e.from == "task-auth-add-oauth" && e.to == "feat-auth" && e.relation == "implements"
}));
}
#[test]
fn test_task_with_deps() {
let mut g = Graph::new();
g.add_node(Node::new("task-setup", "Setup Environment"));
g.add_node(Node::new("task-config", "Write Config"));
let task_id = g.add_task("Deploy App", None, &["task-setup".into(), "Write Config".into()], &[], None);
assert_eq!(task_id, "task-deploy-app");
let deps: Vec<_> = g.edges.iter()
.filter(|e| e.from == "task-deploy-app" && e.relation == "depends_on")
.collect();
assert_eq!(deps.len(), 2);
}
}
#[cfg(test)]
mod merge_feature_nodes_tests {
use super::*;
#[test]
fn test_basic_merge() {
let mut g = Graph::new();
g.add_feature("Auth", &[
TaskSpec { title: "Old Task 1".into(), status: None, tags: vec![], deps: vec![] },
TaskSpec { title: "Old Task 2".into(), status: None, tags: vec![], deps: vec![] },
]);
let mut incoming = Graph::new();
incoming.add_node({
let mut n = Node::new("new-task-a", "New Task A");
n.node_type = Some("task".into());
n
});
incoming.add_node({
let mut n = Node::new("new-task-b", "New Task B");
n.node_type = Some("task".into());
n
});
let (removed, added) = g.merge_feature_nodes("feat-auth", incoming);
assert_eq!(removed, 2);
assert_eq!(added, 2);
assert!(g.get_node("task-auth-old-task-1").is_none());
assert!(g.get_node("task-auth-old-task-2").is_none());
assert!(g.get_node("new-task-a").is_some());
assert!(g.get_node("new-task-b").is_some());
assert!(g.get_node("feat-auth").is_some());
let implements: Vec<_> = g.edges.iter()
.filter(|e| e.relation == "implements" && e.to == "feat-auth")
.collect();
assert_eq!(implements.len(), 2);
}
#[test]
fn test_edge_cascade() {
let mut g = Graph::new();
g.add_feature("Auth", &[
TaskSpec { title: "Task X".into(), status: None, tags: vec![], deps: vec![] },
]);
g.add_edge(Edge::new("task-auth-task-x", "some-other-node", "related_to"));
g.add_node(Node::new("some-other-node", "Other"));
assert!(g.edges.iter().any(|e| e.from == "task-auth-task-x"));
let (removed, _added) = g.merge_feature_nodes("feat-auth", Graph::new());
assert_eq!(removed, 1);
assert!(g.get_node("task-auth-task-x").is_none());
assert!(!g.edges.iter().any(|e| e.from == "task-auth-task-x" || e.to == "task-auth-task-x"));
}
#[test]
fn test_empty_merge() {
let mut g = Graph::new();
g.add_feature("Auth", &[
TaskSpec { title: "Task 1".into(), status: None, tags: vec![], deps: vec![] },
TaskSpec { title: "Task 2".into(), status: None, tags: vec![], deps: vec![] },
]);
let (removed, added) = g.merge_feature_nodes("feat-auth", Graph::new());
assert_eq!(removed, 2);
assert_eq!(added, 0);
assert!(g.get_node("feat-auth").is_some());
let implements: Vec<_> = g.edges.iter()
.filter(|e| e.relation == "implements" && e.to == "feat-auth")
.collect();
assert_eq!(implements.len(), 0);
}
#[test]
fn test_edge_dedup_on_merge() {
let mut g = Graph::new();
g.add_feature("Auth", &[]);
let mut incoming = Graph::new();
incoming.add_node({
let mut n = Node::new("task-new", "New Task");
n.node_type = Some("task".into());
n
});
g.merge_feature_nodes("feat-auth", incoming.clone());
g.remove_node("task-new");
let mut incoming2 = Graph::new();
incoming2.add_node({
let mut n = Node::new("task-new", "New Task");
n.node_type = Some("task".into());
n
});
g.merge_feature_nodes("feat-auth", incoming2);
let implements: Vec<_> = g.edges.iter()
.filter(|e| e.from == "task-new" && e.to == "feat-auth" && e.relation == "implements")
.collect();
assert_eq!(implements.len(), 1);
}
#[test]
fn test_infer_node_type_known_prefixes() {
assert_eq!(infer_node_type("file:src/main.rs"), Some("file"));
assert_eq!(infer_node_type("fn:my_func"), Some("function"));
assert_eq!(infer_node_type("func:my_func"), Some("function"));
assert_eq!(infer_node_type("struct:MyStruct"), Some("class"));
assert_eq!(infer_node_type("class:MyClass"), Some("class"));
assert_eq!(infer_node_type("mod:mymod"), Some("module"));
assert_eq!(infer_node_type("module:mymod"), Some("module"));
assert_eq!(infer_node_type("method:do_thing"), Some("method"));
assert_eq!(infer_node_type("trait:MyTrait"), Some("trait"));
assert_eq!(infer_node_type("interface:IFoo"), Some("trait"));
assert_eq!(infer_node_type("enum:Color"), Some("enum"));
assert_eq!(infer_node_type("const:MAX_SIZE"), Some("constant"));
assert_eq!(infer_node_type("static:INSTANCE"), Some("constant"));
assert_eq!(infer_node_type("test:test_foo"), Some("test"));
assert_eq!(infer_node_type("impl:MyStruct"), Some("impl"));
}
#[test]
fn test_infer_node_type_unknown_prefix() {
assert_eq!(infer_node_type("task-auth-login"), None);
assert_eq!(infer_node_type("feat-pipeline"), None);
assert_eq!(infer_node_type("random-id"), None);
assert_eq!(infer_node_type(""), None);
}
#[test]
fn test_infer_node_type_no_colon() {
assert_eq!(infer_node_type("file"), Some("file"));
assert_eq!(infer_node_type("something"), None);
}
#[test]
fn test_add_node_auto_infers_type() {
let mut g = Graph::new();
let node = Node::new("fn:process_data", "Process Data");
assert!(node.node_type.is_none()); g.add_node(node);
let added = g.get_node("fn:process_data").unwrap();
assert_eq!(added.node_type.as_deref(), Some("function"));
}
#[test]
fn test_add_node_does_not_override_explicit_type() {
let mut g = Graph::new();
let mut node = Node::new("fn:process_data", "Process Data");
node.node_type = Some("custom".to_string());
g.add_node(node);
let added = g.get_node("fn:process_data").unwrap();
assert_eq!(added.node_type.as_deref(), Some("custom"));
}
#[test]
fn test_add_node_no_infer_for_unknown_prefix() {
let mut g = Graph::new();
let node = Node::new("task-auth-login", "Login task");
g.add_node(node);
let added = g.get_node("task-auth-login").unwrap();
assert!(added.node_type.is_none());
}
}