Skip to main content

gid_core/
graph.rs

1use std::collections::HashMap;
2use serde::{Deserialize, Serialize};
3use crate::task_graph_knowledge::{KnowledgeNode, KnowledgeGraph, KnowledgeManagement};
4
5/// A complete GID graph with nodes and edges.
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct Graph {
8    #[serde(default)]
9    pub project: Option<ProjectMeta>,
10    #[serde(default)]
11    pub nodes: Vec<Node>,
12    #[serde(default)]
13    pub edges: Vec<Edge>,
14}
15
16/// Project metadata.
17/// Accepts either a string (`project: myproject`) or a struct (`project: {name: myproject}`).
18#[derive(Debug, Clone, Serialize)]
19pub struct ProjectMeta {
20    pub name: String,
21    #[serde(default)]
22    pub description: Option<String>,
23}
24
25impl<'de> serde::Deserialize<'de> for ProjectMeta {
26    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
27    where
28        D: serde::Deserializer<'de>,
29    {
30        use serde::de;
31
32        struct ProjectMetaVisitor;
33
34        impl<'de> de::Visitor<'de> for ProjectMetaVisitor {
35            type Value = ProjectMeta;
36
37            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
38                formatter.write_str("a string or a map with 'name' field")
39            }
40
41            fn visit_str<E>(self, v: &str) -> std::result::Result<ProjectMeta, E>
42            where
43                E: de::Error,
44            {
45                Ok(ProjectMeta { name: v.to_string(), description: None })
46            }
47
48            fn visit_map<M>(self, map: M) -> std::result::Result<ProjectMeta, M::Error>
49            where
50                M: de::MapAccess<'de>,
51            {
52                #[derive(serde::Deserialize)]
53                struct ProjectMetaInner {
54                    name: String,
55                    #[serde(default)]
56                    description: Option<String>,
57                }
58                let inner = ProjectMetaInner::deserialize(de::value::MapAccessDeserializer::new(map))?;
59                Ok(ProjectMeta { name: inner.name, description: inner.description })
60            }
61        }
62
63        deserializer.deserialize_any(ProjectMetaVisitor)
64    }
65}
66
67/// A node in the graph (task, code file, component, etc.)
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Node {
70    pub id: String,
71    pub title: String,
72    #[serde(default)]
73    pub status: NodeStatus,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub description: Option<String>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub assigned_to: Option<String>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub tags: Vec<String>,
80    /// Priority 0–255. SQLite stores as INTEGER; values outside 0–255 are clamped on read.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub priority: Option<u8>,
83    /// Node type: task, file, component, feature, layer, etc.
84    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
85    pub node_type: Option<String>,
86    /// Knowledge storage: findings, file cache, and tool history.
87    #[serde(default, skip_serializing_if = "KnowledgeNode::is_empty")]
88    pub knowledge: KnowledgeNode,
89    /// Additional metadata.
90    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
91    pub metadata: HashMap<String, serde_json::Value>,
92
93    // ── Code-graph fields (populated by `gid extract`, None for task nodes) ──
94
95    /// File path relative to the project root.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub file_path: Option<String>,
98    /// Programming language.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub lang: Option<String>,
101    /// Start line number in the source file.
102    /// Note: stored as INTEGER in SQLite; clamped to usize range on read.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub start_line: Option<usize>,
105    /// End line number in the source file.
106    /// Note: stored as INTEGER in SQLite; clamped to usize range on read.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub end_line: Option<usize>,
109    /// Function/method signature.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub signature: Option<String>,
112    /// Visibility: public, private, crate, etc.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub visibility: Option<String>,
115    /// Documentation comment extracted from source.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub doc_comment: Option<String>,
118    /// Hash of the body content (for change detection).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub body_hash: Option<String>,
121    /// Code-level kind: Function, Struct, Impl, Trait, Enum, etc.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub node_kind: Option<String>,
124
125    // ── Provenance fields ──
126
127    /// Owner of this node (person or team).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub owner: Option<String>,
130    /// Source of this node (e.g., "extract", "manual", "import").
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub source: Option<String>,
133    /// Repository this node belongs to.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub repo: Option<String>,
136
137    // ── Hierarchy & structure fields ──
138
139    /// Parent node ID (for hierarchical relationships).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub parent_id: Option<String>,
142    /// Depth in the node hierarchy (0 = root).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub depth: Option<u32>,
145
146    // ── Analysis fields ──
147
148    /// Complexity score (e.g., cyclomatic complexity).
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub complexity: Option<f64>,
151    /// Whether this node represents a public API surface.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub is_public: Option<bool>,
154    /// Full body/content of the node (source code, description text, etc.).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub body: Option<String>,
157
158    // ── Timestamps ──
159
160    /// ISO-8601 creation timestamp.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub created_at: Option<String>,
163    /// ISO-8601 last-updated timestamp.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub updated_at: Option<String>,
166}
167
168impl Node {
169    pub fn new(id: &str, title: &str) -> Self {
170        Self {
171            id: id.to_string(),
172            title: title.to_string(),
173            status: NodeStatus::Todo,
174            description: None,
175            assigned_to: None,
176            tags: Vec::new(),
177            priority: None,
178            node_type: None,
179            knowledge: KnowledgeNode::default(),
180            metadata: HashMap::new(),
181            file_path: None,
182            lang: None,
183            start_line: None,
184            end_line: None,
185            signature: None,
186            visibility: None,
187            doc_comment: None,
188            body_hash: None,
189            node_kind: None,
190            owner: None,
191            source: None,
192            repo: None,
193            parent_id: None,
194            depth: None,
195            complexity: None,
196            is_public: None,
197            body: None,
198            created_at: None,
199            updated_at: None,
200        }
201    }
202
203    pub fn with_description(mut self, desc: &str) -> Self {
204        self.description = Some(desc.to_string());
205        self
206    }
207
208    pub fn with_status(mut self, status: NodeStatus) -> Self {
209        self.status = status;
210        self
211    }
212
213    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
214        self.tags = tags;
215        self
216    }
217
218    pub fn with_priority(mut self, priority: u8) -> Self {
219        self.priority = Some(priority);
220        self
221    }
222}
223
224/// Status of a node.
225#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226#[serde(rename_all = "lowercase")]
227pub enum NodeStatus {
228    Todo,
229    #[serde(alias = "in_progress", alias = "in-progress")]
230    InProgress,
231    Done,
232    Blocked,
233    Cancelled,
234    /// Task execution failed (verify failed, sub-agent error, etc.)
235    Failed,
236    /// Task needs human/re-planner intervention (merge conflict, structural issue)
237    #[serde(alias = "needs_resolution", alias = "needs-resolution")]
238    NeedsResolution,
239}
240
241impl Default for NodeStatus {
242    fn default() -> Self {
243        Self::Todo
244    }
245}
246
247impl std::fmt::Display for NodeStatus {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        match self {
250            NodeStatus::Todo => write!(f, "todo"),
251            NodeStatus::InProgress => write!(f, "in_progress"),
252            NodeStatus::Done => write!(f, "done"),
253            NodeStatus::Blocked => write!(f, "blocked"),
254            NodeStatus::Cancelled => write!(f, "cancelled"),
255            NodeStatus::Failed => write!(f, "failed"),
256            NodeStatus::NeedsResolution => write!(f, "needs_resolution"),
257        }
258    }
259}
260
261impl std::str::FromStr for NodeStatus {
262    type Err = anyhow::Error;
263    fn from_str(s: &str) -> Result<Self, Self::Err> {
264        match s {
265            "todo" => Ok(NodeStatus::Todo),
266            "in_progress" | "in-progress" => Ok(NodeStatus::InProgress),
267            "done" => Ok(NodeStatus::Done),
268            "blocked" => Ok(NodeStatus::Blocked),
269            "cancelled" => Ok(NodeStatus::Cancelled),
270            "failed" => Ok(NodeStatus::Failed),
271            "needs_resolution" | "needs-resolution" => Ok(NodeStatus::NeedsResolution),
272            _ => Err(anyhow::anyhow!("Unknown status: {}", s)),
273        }
274    }
275}
276
277/// An edge (relationship) between two nodes.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct Edge {
280    pub from: String,
281    pub to: String,
282    #[serde(default = "default_relation")]
283    pub relation: String,
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub weight: Option<f64>,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub confidence: Option<f64>,
288    /// Additional edge metadata, serialized as JSON in SQLite.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub metadata: Option<serde_json::Value>,
291}
292
293fn default_relation() -> String {
294    "depends_on".to_string()
295}
296
297impl Edge {
298    pub fn new(from: &str, to: &str, relation: &str) -> Self {
299        Self {
300            from: from.to_string(),
301            to: to.to_string(),
302            relation: relation.to_string(),
303            weight: None,
304            confidence: None,
305            metadata: None,
306        }
307    }
308
309    pub fn depends_on(from: &str, to: &str) -> Self {
310        Self::new(from, to, "depends_on")
311    }
312
313    /// Extract the source from edge metadata (e.g. "extract", "auto-bridge", "project")
314    pub fn source(&self) -> Option<&str> {
315        self.metadata.as_ref()
316            .and_then(|m| m.get("source"))
317            .and_then(|v| v.as_str())
318    }
319}
320
321/// Task specification for `add_feature()`.
322#[derive(Debug, Clone)]
323pub struct TaskSpec {
324    pub title: String,
325    pub status: Option<NodeStatus>,  // default: Todo
326    pub tags: Vec<String>,           // default: []
327    pub deps: Vec<String>,           // titles of tasks this depends on
328}
329
330/// Infer the node type from the ID prefix (text before the first `:`).
331///
332/// Returns `Some(type_name)` if the prefix is recognized, `None` otherwise.
333///
334/// # Examples
335/// ```
336/// use gid_core::infer_node_type;
337/// assert_eq!(infer_node_type("file:src/main.rs"), Some("file"));
338/// assert_eq!(infer_node_type("fn:my_func"), Some("function"));
339/// assert_eq!(infer_node_type("struct:MyStruct"), Some("class"));
340/// assert_eq!(infer_node_type("unknown-id"), None);
341/// ```
342pub fn infer_node_type(id: &str) -> Option<&str> {
343    let prefix = id.split(':').next()?;
344    match prefix {
345        "file" => Some("file"),
346        "fn" | "func" => Some("function"),
347        "struct" | "class" => Some("class"),
348        "mod" | "module" => Some("module"),
349        "method" => Some("method"),
350        "trait" | "interface" => Some("trait"),
351        "enum" => Some("enum"),
352        "const" | "static" => Some("constant"),
353        "test" => Some("test"),
354        "impl" => Some("impl"),
355        _ => None,
356    }
357}
358
359// ─── Graph operations ────────────────────────────────────────
360
361impl Graph {
362    pub fn new() -> Self {
363        Self::default()
364    }
365
366    // ── Node operations ──
367
368    pub fn get_node(&self, id: &str) -> Option<&Node> {
369        self.nodes.iter().find(|n| n.id == id)
370    }
371
372    pub fn get_node_mut(&mut self, id: &str) -> Option<&mut Node> {
373        self.nodes.iter_mut().find(|n| n.id == id)
374    }
375
376    pub fn add_node(&mut self, node: Node) {
377        if self.get_node(&node.id).is_none() {
378            let mut node = node;
379            // Auto-infer node_type from ID prefix if not already set
380            if node.node_type.is_none() {
381                if let Some(inferred) = infer_node_type(&node.id) {
382                    node.node_type = Some(inferred.to_string());
383                }
384            }
385            self.nodes.push(node);
386        }
387    }
388
389    pub fn remove_node(&mut self, id: &str) -> Option<Node> {
390        let pos = self.nodes.iter().position(|n| n.id == id)?;
391        let node = self.nodes.remove(pos);
392        // Remove associated edges
393        self.edges.retain(|e| e.from != id && e.to != id);
394        Some(node)
395    }
396
397    pub fn update_status(&mut self, id: &str, status: NodeStatus) -> bool {
398        if let Some(node) = self.get_node_mut(id) {
399            node.status = status;
400            true
401        } else {
402            false
403        }
404    }
405
406    // ── Edge operations ──
407
408    pub fn add_edge(&mut self, edge: Edge) {
409        // Avoid duplicates
410        let exists = self.edges.iter().any(|e| {
411            e.from == edge.from && e.to == edge.to && e.relation == edge.relation
412        });
413        if !exists {
414            self.edges.push(edge);
415        }
416    }
417
418    pub fn remove_edge(&mut self, from: &str, to: &str, relation: Option<&str>) {
419        self.edges.retain(|e| {
420            !(e.from == from && e.to == to && relation.map_or(true, |r| e.relation == r))
421        });
422    }
423
424    /// Add an edge with deduplication check.
425    ///
426    /// Returns `true` if the edge was added (new), `false` if it already existed.
427    /// An edge is considered duplicate if the (from, to, relation) triple matches.
428    ///
429    /// # Examples
430    ///
431    /// ```
432    /// use gid_core::{Graph, Edge};
433    ///
434    /// let mut g = Graph::new();
435    /// let edge = Edge::new("a", "b", "depends_on");
436    /// assert!(g.add_edge_dedup(edge.clone())); // Returns true (new edge)
437    /// assert!(!g.add_edge_dedup(edge)); // Returns false (duplicate)
438    /// ```
439    pub fn add_edge_dedup(&mut self, edge: Edge) -> bool {
440        let exists = self.edges.iter().any(|e| {
441            e.from == edge.from && e.to == edge.to && e.relation == edge.relation
442        });
443        if !exists {
444            self.edges.push(edge);
445            true
446        } else {
447            false
448        }
449    }
450
451    /// Ensures a node ID is unique by appending -2, -3, etc. if needed.
452    fn ensure_unique_id(&self, base: String) -> String {
453        if self.get_node(&base).is_none() {
454            return base;
455        }
456        for i in 2..1000 {
457            let candidate = format!("{}-{}", base, i);
458            if self.get_node(&candidate).is_none() {
459                return candidate;
460            }
461        }
462        format!("{}-overflow", base)
463    }
464
465    /// Create a feature node with task nodes and all edges in one operation.
466    ///
467    /// - Creates `feat-{slug}` feature node
468    /// - Creates `task-{feature_slug}-{task_slug}` task nodes
469    /// - Adds `implements` edges from each task to the feature
470    /// - Adds `depends_on` edges between tasks per TaskSpec.deps (matched by title)
471    /// - Returns the feature node ID
472    pub fn add_feature(&mut self, name: &str, tasks: &[TaskSpec]) -> String {
473        use crate::slugify::slugify;
474
475        let feature_slug = slugify(name);
476        let feat_id = self.ensure_unique_id(format!("feat-{}", feature_slug));
477
478        let mut feat = Node::new(&feat_id, name);
479        feat.node_type = Some("feature".into());
480        feat.status = NodeStatus::Todo;
481        self.add_node(feat);
482
483        // Map title -> actual task ID for dep resolution
484        let mut task_ids: HashMap<String, String> = HashMap::new();
485
486        for spec in tasks {
487            let task_slug = slugify(&spec.title);
488            let base_id = format!("task-{}-{}", feature_slug, task_slug);
489            let task_id = self.ensure_unique_id(base_id);
490
491            let mut task = Node::new(&task_id, &spec.title);
492            task.node_type = Some("task".into());
493            task.status = spec.status.clone().unwrap_or(NodeStatus::Todo);
494            task.tags = spec.tags.clone();
495            self.add_node(task);
496
497            // implements edge: task -> feature
498            self.add_edge_dedup(Edge::new(&task_id, &feat_id, "implements"));
499
500            task_ids.insert(spec.title.clone(), task_id);
501        }
502
503        // Add dependency edges between tasks
504        for spec in tasks {
505            if let Some(from_id) = task_ids.get(&spec.title) {
506                for dep_title in &spec.deps {
507                    if let Some(to_id) = task_ids.get(dep_title.as_str()) {
508                        self.add_edge_dedup(Edge::new(from_id, to_id, "depends_on"));
509                    }
510                }
511            }
512        }
513
514        feat_id
515    }
516
517    /// Add a standalone task node (no parent feature required).
518    /// Returns the task node ID.
519    pub fn add_task(
520        &mut self,
521        title: &str,
522        for_feature: Option<&str>,
523        depends_on: &[String],
524        tags: &[String],
525        priority: Option<u8>,
526    ) -> String {
527        use crate::slugify::slugify;
528
529        let task_slug = slugify(title);
530        let base_id = if let Some(feat_id) = for_feature {
531            // If attached to a feature, prefix with feature slug
532            let feat_slug = feat_id.strip_prefix("feat-").unwrap_or(feat_id);
533            format!("task-{}-{}", feat_slug, task_slug)
534        } else {
535            format!("task-{}", task_slug)
536        };
537        let task_id = self.ensure_unique_id(base_id);
538
539        let mut task = Node::new(&task_id, title);
540        task.node_type = Some("task".into());
541        task.status = NodeStatus::Todo;
542        task.tags = tags.to_vec();
543        task.priority = priority;
544        self.add_node(task);
545
546        // If for_feature specified, add implements edge
547        if let Some(feat_id) = for_feature {
548            self.add_edge_dedup(Edge::new(&task_id, feat_id, "implements"));
549        }
550
551        // Add depends_on edges - support both exact IDs and fuzzy resolution
552        for dep in depends_on {
553            let resolved = self.resolve_node(dep);
554            if let Some(dep_node) = resolved.first() {
555                let dep_id = dep_node.id.clone();
556                self.add_edge_dedup(Edge::new(&task_id, &dep_id, "depends_on"));
557            } else {
558                eprintln!("⚠ Could not resolve dependency: {}", dep);
559            }
560        }
561
562        task_id
563    }
564
565    /// Merge incoming nodes into this graph, scoped to a specific feature.
566    ///
567    /// 1. Finds all existing task nodes that `implements` the target feature
568    /// 2. Removes those old task nodes (cascading edge cleanup via remove_node)
569    /// 3. Adds all incoming nodes
570    /// 4. Adds `implements` edges from incoming task nodes to the feature
571    /// 5. Adds incoming edges with deduplication
572    ///
573    /// Returns (removed_count, added_count) for reporting.
574    pub fn merge_feature_nodes(&mut self, feature_id: &str, incoming: Graph) -> (usize, usize) {
575        // Step 1: Find old feature tasks
576        let old_task_ids: Vec<String> = self.edges.iter()
577            .filter(|e| e.to == feature_id && e.relation == "implements")
578            .map(|e| e.from.clone())
579            .collect();
580
581        let removed = old_task_ids.len();
582
583        // Step 2: Remove old task nodes (remove_node cascades edges)
584        for id in &old_task_ids {
585            self.remove_node(id);
586        }
587
588        // Step 3: Collect incoming node IDs
589        let incoming_node_ids: std::collections::HashSet<String> = incoming.nodes.iter()
590            .map(|n| n.id.clone())
591            .collect();
592        let added = incoming.nodes.len();
593
594        // Step 4: Add all incoming nodes
595        for node in incoming.nodes {
596            self.add_node(node);
597        }
598
599        // Step 5: Add implements edges for each new node
600        for id in &incoming_node_ids {
601            self.add_edge_dedup(Edge::new(id, feature_id, "implements"));
602        }
603
604        // Step 6: Add incoming edges with dedup
605        for edge in incoming.edges {
606            self.add_edge_dedup(edge);
607        }
608
609        (removed, added)
610    }
611
612    /// Resolve a node reference to actual node(s) using a 7-tier priority cascade.
613    ///
614    /// Priority tiers (highest to lowest):
615    /// 1. Exact ID match
616    /// 2. Exact title match (case-insensitive)
617    /// 3. Structural segment match (`:`, `-`, `/` delimiters)
618    /// 4. Word segment match (`_` delimiter)
619    /// 5. File path match
620    /// 6. Title substring match (case-insensitive)
621    /// 7. ID substring match (case-insensitive)
622    ///
623    /// Returns a vector of matching nodes. Empty vector if no match found.
624    /// May return multiple nodes if there's ambiguity (e.g., multiple substring matches).
625    ///
626    /// # Examples
627    ///
628    /// ```
629    /// use gid_core::{Graph, Node};
630    ///
631    /// let mut g = Graph::new();
632    /// g.add_node(Node::new("feat-auth", "Authentication Feature"));
633    /// g.add_node(Node::new("impl-jwt", "Implement JWT validation"));
634    ///
635    /// // Exact ID match
636    /// let results = g.resolve_node("feat-auth");
637    /// assert_eq!(results.len(), 1);
638    /// assert_eq!(results[0].id, "feat-auth");
639    ///
640    /// // Case-insensitive title match
641    /// let results = g.resolve_node("authentication feature");
642    /// assert_eq!(results.len(), 1);
643    ///
644    /// // No match
645    /// let results = g.resolve_node("nonexistent");
646    /// assert_eq!(results.len(), 0);
647    /// ```
648    pub fn resolve_node(&self, reference: &str) -> Vec<&Node> {
649        let reference_lower = reference.to_lowercase();
650
651        // Tier 1: Exact ID match
652        if let Some(node) = self.nodes.iter().find(|n| n.id == reference) {
653            return vec![node];
654        }
655
656        // Tier 2: Exact title match (case-insensitive)
657        let exact_title: Vec<&Node> = self.nodes.iter()
658            .filter(|n| n.title.to_lowercase() == reference_lower)
659            .collect();
660        if !exact_title.is_empty() {
661            return exact_title;
662        }
663
664        // Tier 3: Structural segment match (: - / delimiters)
665        let structural_segments = extract_segments(&reference_lower, &[':', '-', '/']);
666        if !structural_segments.is_empty() {
667            let matches: Vec<&Node> = self.nodes.iter()
668                .filter(|n| {
669                    let id_segs = extract_segments(&n.id.to_lowercase(), &[':', '-', '/']);
670                    let title_segs = extract_segments(&n.title.to_lowercase(), &[':', '-', '/']);
671                    segments_match(&structural_segments, &id_segs) || 
672                    segments_match(&structural_segments, &title_segs)
673                })
674                .collect();
675            if !matches.is_empty() {
676                return matches;
677            }
678        }
679
680        // Tier 4: Word segment match (_ delimiter)
681        let word_segments = extract_segments(&reference_lower, &['_']);
682        if !word_segments.is_empty() {
683            let matches: Vec<&Node> = self.nodes.iter()
684                .filter(|n| {
685                    let id_segs = extract_segments(&n.id.to_lowercase(), &['_']);
686                    let title_segs = extract_segments(&n.title.to_lowercase(), &['_']);
687                    segments_match(&word_segments, &id_segs) || 
688                    segments_match(&word_segments, &title_segs)
689                })
690                .collect();
691            if !matches.is_empty() {
692                return matches;
693            }
694        }
695
696        // Tier 5: File path match
697        let matches: Vec<&Node> = self.nodes.iter()
698            .filter(|n| {
699                n.file_path.as_ref()
700                    .map(|fp| fp.to_lowercase().contains(&reference_lower))
701                    .unwrap_or(false)
702            })
703            .collect();
704        if !matches.is_empty() {
705            return matches;
706        }
707
708        // Tier 6: Title substring match (case-insensitive)
709        let matches: Vec<&Node> = self.nodes.iter()
710            .filter(|n| n.title.to_lowercase().contains(&reference_lower))
711            .collect();
712        if !matches.is_empty() {
713            return matches;
714        }
715
716        // Tier 7: ID substring match (case-insensitive)
717        let matches: Vec<&Node> = self.nodes.iter()
718            .filter(|n| n.id.to_lowercase().contains(&reference_lower))
719            .collect();
720        
721        matches
722    }
723
724    pub fn edges_from(&self, id: &str) -> Vec<&Edge> {
725        self.edges.iter().filter(|e| e.from == id).collect()
726    }
727
728    pub fn edges_to(&self, id: &str) -> Vec<&Edge> {
729        self.edges.iter().filter(|e| e.to == id).collect()
730    }
731
732    // ── Layer filtering helpers ──
733
734    /// Get all code nodes (source == "extract")
735    pub fn code_nodes(&self) -> Vec<&Node> {
736        self.nodes.iter().filter(|n| n.source.as_deref() == Some("extract")).collect()
737    }
738
739    /// Get all project nodes (source == "project" or legacy None)
740    pub fn project_nodes(&self) -> Vec<&Node> {
741        // TODO: after T4.1 migration backfills source on all nodes, remove the None branch
742        self.nodes.iter().filter(|n| {
743            n.source.as_deref().map_or(true, |s| s == "project")
744        }).collect()
745    }
746
747    /// Get all code edges (source == "extract")
748    pub fn code_edges(&self) -> Vec<&Edge> {
749        self.edges.iter().filter(|e| e.source() == Some("extract")).collect()
750    }
751
752    /// Get all project edges (not code, not bridge)
753    pub fn project_edges(&self) -> Vec<&Edge> {
754        self.edges.iter().filter(|e| {
755            let src = e.source();
756            src != Some("extract") && src != Some("auto-bridge")
757        }).collect()
758    }
759
760    /// Get all bridge edges (source == "auto-bridge")
761    pub fn bridge_edges(&self) -> Vec<&Edge> {
762        self.edges.iter().filter(|e| e.source() == Some("auto-bridge")).collect()
763    }
764
765    /// Get tasks that are ready (todo + all depends_on are done).
766    /// Only considers project nodes; code nodes are excluded.
767    ///
768    /// Uses pre-built HashMaps for O(N+M) instead of O(N×M×N).
769    pub fn ready_tasks(&self) -> Vec<&Node> {
770        // Build O(1) lookup: node_id → status
771        let status_map: HashMap<&str, &NodeStatus> = self
772            .nodes
773            .iter()
774            .map(|n| (n.id.as_str(), &n.status))
775            .collect();
776
777        // Build O(1) adjacency: from_id → Vec<&Edge> (depends_on only)
778        let mut dep_edges: HashMap<&str, Vec<&Edge>> = HashMap::new();
779        for e in &self.edges {
780            if e.relation == "depends_on" {
781                dep_edges.entry(e.from.as_str()).or_default().push(e);
782            }
783        }
784
785        self.project_nodes()
786            .into_iter()
787            .filter(|n| n.status == NodeStatus::Todo)
788            .filter(|n| {
789                match dep_edges.get(n.id.as_str()) {
790                    None => true, // no dependencies → ready
791                    Some(deps) => deps.iter().all(|e| {
792                        status_map
793                            .get(e.to.as_str())
794                            .map_or(true, |s| **s == NodeStatus::Done)
795                    }),
796                }
797            })
798            .collect()
799    }
800
801    /// Get tasks by status.
802    pub fn tasks_by_status(&self, status: &NodeStatus) -> Vec<&Node> {
803        self.nodes.iter().filter(|n| &n.status == status).collect()
804    }
805
806    /// Summary statistics (counts only project nodes, not code nodes).
807    pub fn summary(&self) -> GraphSummary {
808        let project_nodes = self.project_nodes();
809        let mut s = GraphSummary {
810            total_nodes: project_nodes.len(),
811            total_edges: self.edges.len(),
812            ..Default::default()
813        };
814        for n in &project_nodes {
815            match n.status {
816                NodeStatus::Todo => s.todo += 1,
817                NodeStatus::InProgress => s.in_progress += 1,
818                NodeStatus::Done => s.done += 1,
819                NodeStatus::Blocked => s.blocked += 1,
820                NodeStatus::Cancelled => s.cancelled += 1,
821                NodeStatus::Failed => s.failed += 1,
822                NodeStatus::NeedsResolution => s.needs_resolution += 1,
823            }
824        }
825        s.ready = self.ready_tasks().len();
826        s
827    }
828
829    /// Get a human-readable text summary of the graph state.
830    pub fn summary_text(&self) -> String {
831        let s = self.summary();
832        let mut lines = vec![
833            format!("Graph: {} nodes, {} edges", s.total_nodes, s.total_edges),
834        ];
835
836        if s.total_nodes > 0 {
837            lines.push(format!(
838                "Status: {} todo, {} in-progress, {} done, {} blocked, {} cancelled",
839                s.todo, s.in_progress, s.done, s.blocked, s.cancelled
840            ));
841            lines.push(format!("Ready tasks: {}", s.ready));
842        }
843
844        // Show project name if available
845        if let Some(ref project) = self.project {
846            lines.insert(0, format!("Project: {}", project.name));
847        }
848
849        lines.join("\n")
850    }
851
852    /// Calculate graph health score (0.0 to 1.0).
853    /// 
854    /// Health is based on:
855    /// - Progress: ratio of done tasks to total
856    /// - Flow: ratio of ready tasks to remaining (non-blocked) tasks
857    /// - Connectivity: graphs with edges are healthier than isolated nodes
858    /// 
859    /// Returns 1.0 for a fully complete graph, 0.0 for an empty or stuck graph.
860    pub fn health(&self) -> f64 {
861        if self.nodes.is_empty() {
862            return 0.0;
863        }
864
865        let s = self.summary();
866        let total = s.total_nodes as f64;
867
868        // Progress score: what fraction is done?
869        let progress = s.done as f64 / total;
870
871        // Flow score: are there ready tasks to work on? (avoid stuck graphs)
872        let remaining = s.todo + s.in_progress;
873        let flow = if remaining == 0 {
874            1.0 // All done, perfect flow
875        } else if s.ready == 0 && s.todo > 0 {
876            0.0 // Stuck: todos exist but none are ready (all blocked by dependencies)
877        } else {
878            (s.ready as f64) / (remaining as f64)
879        };
880
881        // Connectivity score: graphs with structure are healthier
882        let connectivity = if self.nodes.len() > 1 {
883            let max_edges = self.nodes.len() * (self.nodes.len() - 1);
884            let actual = self.edges.len().min(max_edges);
885            (actual as f64 / max_edges as f64).min(1.0)
886        } else {
887            1.0 // Single node is "connected"
888        };
889
890        // Blocked penalty: heavily blocked graphs are unhealthy
891        let blocked_ratio = s.blocked as f64 / total;
892        let blocked_penalty = 1.0 - blocked_ratio;
893
894        // Weighted combination
895        let health = 0.4 * progress + 0.3 * flow + 0.1 * connectivity + 0.2 * blocked_penalty;
896        health.clamp(0.0, 1.0)
897    }
898
899    /// Mark a task as done. Returns true if found and updated.
900    pub fn mark_task_done(&mut self, node_id: &str) -> bool {
901        self.update_status(node_id, NodeStatus::Done)
902    }
903
904    /// Get executable tasks (alias for ready_tasks, returns owned Task structs).
905    pub fn get_executable_tasks(&self) -> Vec<Task> {
906        self.ready_tasks()
907            .into_iter()
908            .map(|node| Task {
909                id: node.id.clone(),
910                title: node.title.clone(),
911                description: node.description.clone(),
912                priority: node.priority,
913            })
914            .collect()
915    }
916}
917
918/// A simplified task representation for execution.
919#[derive(Debug, Clone)]
920pub struct Task {
921    pub id: String,
922    pub title: String,
923    pub description: Option<String>,
924    pub priority: Option<u8>,
925}
926
927#[derive(Debug, Default)]
928pub struct GraphSummary {
929    pub total_nodes: usize,
930    pub total_edges: usize,
931    pub todo: usize,
932    pub in_progress: usize,
933    pub done: usize,
934    pub blocked: usize,
935    pub cancelled: usize,
936    pub failed: usize,
937    pub needs_resolution: usize,
938    pub ready: usize,
939}
940
941// Implement knowledge management for Graph so users can call
942// graph.store_finding(), graph.cache_file(), etc. directly.
943impl KnowledgeGraph for Graph {
944    fn get_knowledge_mut(&mut self, node_id: &str) -> Option<&mut KnowledgeNode> {
945        self.nodes.iter_mut()
946            .find(|n| n.id == node_id)
947            .map(|n| &mut n.knowledge)
948    }
949
950    fn get_knowledge(&self, node_id: &str) -> Option<&KnowledgeNode> {
951        self.nodes.iter()
952            .find(|n| n.id == node_id)
953            .map(|n| &n.knowledge)
954    }
955
956    fn get_incoming_edges(&self, node_id: &str) -> Vec<String> {
957        self.edges.iter()
958            .filter(|e| e.to == node_id)
959            .map(|e| e.from.clone())
960            .collect()
961    }
962}
963
964impl KnowledgeManagement for Graph {}
965
966impl std::fmt::Display for GraphSummary {
967    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
968        write!(
969            f,
970            "{} nodes, {} edges | todo={} progress={} done={} blocked={} failed={} cancelled={} | ready={}",
971            self.total_nodes, self.total_edges,
972            self.todo, self.in_progress, self.done, self.blocked, self.failed, self.cancelled,
973            self.ready,
974        )
975    }
976}
977
978
979// ── Helper functions for resolve_node ──
980
981/// Extract segments from text using the given delimiters.
982fn extract_segments(text: &str, delimiters: &[char]) -> Vec<String> {
983    let mut segments = vec![text.to_string()];
984    
985    for &delimiter in delimiters {
986        let mut new_segments = Vec::new();
987        for segment in segments {
988            new_segments.extend(
989                segment.split(delimiter)
990                    .filter(|s| !s.is_empty())
991                    .map(|s| s.to_string())
992            );
993        }
994        segments = new_segments;
995    }
996    
997    segments
998}
999
1000/// Check if all query segments appear in target segments (order-independent).
1001fn segments_match(query_segments: &[String], target_segments: &[String]) -> bool {
1002    if query_segments.is_empty() {
1003        return false;
1004    }
1005    query_segments.iter().all(|q| target_segments.contains(q))
1006}
1007
1008#[cfg(test)]
1009mod layer_filter_tests {
1010    use super::*;
1011
1012    fn mixed_graph() -> Graph {
1013        let mut g = Graph::new();
1014        // Project nodes
1015        let mut task = Node::new("task-1", "My Task");
1016        task.source = Some("project".to_string());
1017        g.add_node(task);
1018
1019        let legacy = Node::new("legacy-1", "Legacy Task");
1020        // No source (legacy)
1021        g.add_node(legacy);
1022
1023        // Code nodes
1024        let mut code = Node::new("fn:main", "main function");
1025        code.source = Some("extract".to_string());
1026        code.node_type = Some("code".to_string());
1027        code.status = NodeStatus::Done;
1028        g.add_node(code);
1029
1030        let mut code2 = Node::new("struct:Config", "Config struct");
1031        code2.source = Some("extract".to_string());
1032        code2.node_type = Some("code".to_string());
1033        code2.status = NodeStatus::Done;
1034        g.add_node(code2);
1035
1036        // Edges
1037        g.add_edge(Edge::new("task-1", "legacy-1", "depends_on"));
1038
1039        let mut code_edge = Edge::new("fn:main", "struct:Config", "calls");
1040        code_edge.metadata = Some(serde_json::json!({"source": "extract"}));
1041        g.add_edge(code_edge);
1042
1043        let mut bridge = Edge::new("task-1", "fn:main", "maps_to");
1044        bridge.metadata = Some(serde_json::json!({"source": "auto-bridge"}));
1045        g.add_edge(bridge);
1046
1047        g
1048    }
1049
1050    #[test]
1051    fn test_edge_source() {
1052        let g = mixed_graph();
1053        // Regular edge has no source
1054        let proj_edge = g.edges.iter().find(|e| e.relation == "depends_on").unwrap();
1055        assert_eq!(proj_edge.source(), None);
1056
1057        let code_edge = g.edges.iter().find(|e| e.relation == "calls").unwrap();
1058        assert_eq!(code_edge.source(), Some("extract"));
1059
1060        let bridge_edge = g.edges.iter().find(|e| e.relation == "maps_to").unwrap();
1061        assert_eq!(bridge_edge.source(), Some("auto-bridge"));
1062    }
1063
1064    #[test]
1065    fn test_code_nodes() {
1066        let g = mixed_graph();
1067        let cn = g.code_nodes();
1068        assert_eq!(cn.len(), 2);
1069        assert!(cn.iter().all(|n| n.source.as_deref() == Some("extract")));
1070    }
1071
1072    #[test]
1073    fn test_project_nodes() {
1074        let g = mixed_graph();
1075        let pn = g.project_nodes();
1076        assert_eq!(pn.len(), 2); // task-1 + legacy-1
1077        assert!(pn.iter().any(|n| n.id == "task-1"));
1078        assert!(pn.iter().any(|n| n.id == "legacy-1"));
1079    }
1080
1081    #[test]
1082    fn test_code_edges() {
1083        let g = mixed_graph();
1084        assert_eq!(g.code_edges().len(), 1);
1085    }
1086
1087    #[test]
1088    fn test_project_edges() {
1089        let g = mixed_graph();
1090        assert_eq!(g.project_edges().len(), 1); // only depends_on
1091    }
1092
1093    #[test]
1094    fn test_bridge_edges() {
1095        let g = mixed_graph();
1096        assert_eq!(g.bridge_edges().len(), 1);
1097    }
1098
1099    #[test]
1100    fn test_summary_excludes_code_nodes() {
1101        let g = mixed_graph();
1102        let s = g.summary();
1103        // Summary should count only project nodes (2), not code nodes (2)
1104        assert_eq!(s.total_nodes, 2);
1105    }
1106
1107    #[test]
1108    fn test_ready_tasks_excludes_code_nodes() {
1109        let mut g = mixed_graph();
1110        // Make legacy-1 done so task-1 becomes ready
1111        g.update_status("legacy-1", NodeStatus::Done);
1112        let ready = g.ready_tasks();
1113        // task-1 should be ready, code nodes should NOT appear
1114        assert!(ready.iter().any(|n| n.id == "task-1"));
1115        assert!(!ready.iter().any(|n| n.source.as_deref() == Some("extract")));
1116    }
1117}
1118
1119#[cfg(test)]
1120mod add_edge_dedup_tests {
1121    use super::*;
1122
1123    #[test]
1124    fn test_new_edge_returns_true() {
1125        let mut g = Graph::new();
1126        g.add_node(Node::new("a", "A"));
1127        g.add_node(Node::new("b", "B"));
1128        let result = g.add_edge_dedup(Edge::new("a", "b", "depends_on"));
1129        assert!(result);
1130        assert_eq!(g.edges.len(), 1);
1131    }
1132
1133    #[test]
1134    fn test_duplicate_returns_false() {
1135        let mut g = Graph::new();
1136        g.add_node(Node::new("a", "A"));
1137        g.add_node(Node::new("b", "B"));
1138        g.add_edge_dedup(Edge::new("a", "b", "depends_on"));
1139        let result = g.add_edge_dedup(Edge::new("a", "b", "depends_on"));
1140        assert!(!result);
1141        assert_eq!(g.edges.len(), 1);
1142    }
1143
1144    #[test]
1145    fn test_same_from_to_different_relation() {
1146        let mut g = Graph::new();
1147        g.add_node(Node::new("a", "A"));
1148        g.add_node(Node::new("b", "B"));
1149        assert!(g.add_edge_dedup(Edge::new("a", "b", "depends_on")));
1150        assert!(g.add_edge_dedup(Edge::new("a", "b", "blocks")));
1151        assert_eq!(g.edges.len(), 2);
1152    }
1153
1154    #[test]
1155    fn test_same_from_relation_different_to() {
1156        let mut g = Graph::new();
1157        g.add_node(Node::new("a", "A"));
1158        g.add_node(Node::new("b", "B"));
1159        g.add_node(Node::new("c", "C"));
1160        assert!(g.add_edge_dedup(Edge::new("a", "b", "depends_on")));
1161        assert!(g.add_edge_dedup(Edge::new("a", "c", "depends_on")));
1162        assert_eq!(g.edges.len(), 2);
1163    }
1164}
1165
1166#[cfg(test)]
1167mod resolve_node_tests {
1168    use super::*;
1169
1170    fn test_graph() -> Graph {
1171        let mut g = Graph::new();
1172        g.add_node(Node::new("task-auth", "Auth Module"));
1173        g.add_node(Node::new("feat:auth:login", "Login Feature"));
1174        g.add_node(Node::new("validate_auth_token", "Token Validator"));
1175        g.add_node(Node::new("file:src/main.rs", "Main Entry"));
1176        g.add_node(Node::new("impl-auth-middleware", "User Login Flow"));
1177        g.add_node(Node::new("task-db", "Database Setup"));
1178        g
1179    }
1180
1181    #[test]
1182    fn test_exact_id_match() {
1183        let g = test_graph();
1184        let results = g.resolve_node("task-auth");
1185        assert_eq!(results.len(), 1);
1186        assert_eq!(results[0].id, "task-auth");
1187    }
1188
1189    #[test]
1190    fn test_exact_title_match_case_insensitive() {
1191        let g = test_graph();
1192        let results = g.resolve_node("auth module");
1193        assert_eq!(results.len(), 1);
1194        assert_eq!(results[0].id, "task-auth");
1195    }
1196
1197    #[test]
1198    fn test_structural_segment_colon() {
1199        let g = test_graph();
1200        // "auth" appears as a segment in "feat:auth:login" when split on ':'
1201        // But it also appears in "task-auth" split on '-', and "validate_auth_token" split on '_'
1202        // and in ID substrings. Tier 3 (structural) should find feat:auth:login and task-auth (split on '-')
1203        let results = g.resolve_node("login");
1204        // "login" is a structural segment of "feat:auth:login" (colon-split)
1205        assert!(results.iter().any(|n| n.id == "feat:auth:login"));
1206    }
1207
1208    #[test]
1209    fn test_word_segment_underscore() {
1210        let g = test_graph();
1211        // "validate" splits on '_' in "validate_auth_token"
1212        let results = g.resolve_node("validate");
1213        assert_eq!(results.len(), 1);
1214        assert_eq!(results[0].id, "validate_auth_token");
1215    }
1216
1217    #[test]
1218    fn test_file_path_match() {
1219        let g = test_graph();
1220        let results = g.resolve_node("main.rs");
1221        assert!(results.iter().any(|n| n.id == "file:src/main.rs"));
1222    }
1223
1224    #[test]
1225    fn test_title_substring() {
1226        let g = test_graph();
1227        // "Login Flow" is a substring of "User Login Flow"
1228        let results = g.resolve_node("Login Flow");
1229        assert_eq!(results.len(), 1);
1230        assert_eq!(results[0].id, "impl-auth-middleware");
1231    }
1232
1233    #[test]
1234    fn test_id_substring() {
1235        let g = test_graph();
1236        // "middleware" is a substring of "impl-auth-middleware"
1237        // But it's also a structural segment (split on '-'), so tier 3 catches it
1238        let results = g.resolve_node("middleware");
1239        assert!(results.iter().any(|n| n.id == "impl-auth-middleware"));
1240    }
1241
1242    #[test]
1243    fn test_zero_matches() {
1244        let g = test_graph();
1245        let results = g.resolve_node("nonexistent_xyz");
1246        assert!(results.is_empty());
1247    }
1248
1249    #[test]
1250    fn test_tier_priority() {
1251        // If query matches exact ID (tier 1), should NOT return title substring matches (tier 6)
1252        let mut g = Graph::new();
1253        g.add_node(Node::new("auth", "Something"));
1254        g.add_node(Node::new("other", "auth related"));
1255        let results = g.resolve_node("auth");
1256        // Only tier 1 (exact ID) should match
1257        assert_eq!(results.len(), 1);
1258        assert_eq!(results[0].id, "auth");
1259    }
1260
1261    #[test]
1262    fn test_multiple_matches_same_tier() {
1263        let mut g = Graph::new();
1264        g.add_node(Node::new("node-1", "Auth Login"));
1265        g.add_node(Node::new("node-2", "Auth Signup"));
1266        // "auth" doesn't match any exact ID or title, structural segment matches both on '-' split? No.
1267        // "auth" as structural segment: "node-1" splits to ["node", "1"], "node-2" splits to ["node", "2"]
1268        // None match. Underscore split: no underscores. File path: no "file:" prefix.
1269        // Title substring (tier 6): both contain "auth" (case-insensitive)
1270        let results = g.resolve_node("auth");
1271        assert_eq!(results.len(), 2);
1272    }
1273}
1274
1275#[cfg(test)]
1276mod add_feature_tests {
1277    use super::*;
1278
1279    #[test]
1280    fn test_basic_feature_with_tasks() {
1281        let mut g = Graph::new();
1282        let tasks = vec![
1283            TaskSpec { title: "Design API".into(), status: None, tags: vec![], deps: vec![] },
1284            TaskSpec { title: "Write Tests".into(), status: None, tags: vec![], deps: vec![] },
1285        ];
1286        let feat_id = g.add_feature("User Auth", &tasks);
1287        assert_eq!(feat_id, "feat-user-auth");
1288
1289        // Feature node exists
1290        let feat = g.get_node("feat-user-auth").unwrap();
1291        assert_eq!(feat.title, "User Auth");
1292        assert_eq!(feat.node_type.as_deref(), Some("feature"));
1293        assert_eq!(feat.status, NodeStatus::Todo);
1294
1295        // Task nodes exist
1296        assert!(g.get_node("task-user-auth-design-api").is_some());
1297        assert!(g.get_node("task-user-auth-write-tests").is_some());
1298
1299        // Implements edges exist
1300        let implements: Vec<_> = g.edges.iter()
1301            .filter(|e| e.relation == "implements" && e.to == "feat-user-auth")
1302            .collect();
1303        assert_eq!(implements.len(), 2);
1304    }
1305
1306    #[test]
1307    fn test_feature_with_deps() {
1308        let mut g = Graph::new();
1309        let tasks = vec![
1310            TaskSpec { title: "Setup DB".into(), status: None, tags: vec![], deps: vec![] },
1311            TaskSpec { title: "Write Schema".into(), status: None, tags: vec![], deps: vec!["Setup DB".into()] },
1312            TaskSpec { title: "Add Migrations".into(), status: None, tags: vec![], deps: vec!["Write Schema".into()] },
1313        ];
1314        let feat_id = g.add_feature("Database", &tasks);
1315        assert_eq!(feat_id, "feat-database");
1316
1317        // depends_on edges
1318        let deps: Vec<_> = g.edges.iter()
1319            .filter(|e| e.relation == "depends_on")
1320            .collect();
1321        assert_eq!(deps.len(), 2);
1322
1323        // Write Schema depends on Setup DB
1324        assert!(g.edges.iter().any(|e| {
1325            e.from == "task-database-write-schema" && e.to == "task-database-setup-db" && e.relation == "depends_on"
1326        }));
1327
1328        // Add Migrations depends on Write Schema
1329        assert!(g.edges.iter().any(|e| {
1330            e.from == "task-database-add-migrations" && e.to == "task-database-write-schema" && e.relation == "depends_on"
1331        }));
1332    }
1333
1334    #[test]
1335    fn test_feature_id_collision() {
1336        let mut g = Graph::new();
1337        let tasks = vec![
1338            TaskSpec { title: "Task A".into(), status: None, tags: vec![], deps: vec![] },
1339        ];
1340        let id1 = g.add_feature("Auth", &tasks);
1341        assert_eq!(id1, "feat-auth");
1342
1343        let id2 = g.add_feature("Auth", &[]);
1344        assert_eq!(id2, "feat-auth-2");
1345
1346        // Both exist
1347        assert!(g.get_node("feat-auth").is_some());
1348        assert!(g.get_node("feat-auth-2").is_some());
1349    }
1350}
1351
1352#[cfg(test)]
1353mod add_task_tests {
1354    use super::*;
1355
1356    #[test]
1357    fn test_standalone_task() {
1358        let mut g = Graph::new();
1359        let task_id = g.add_task("Fix login bug", None, &[], &[], None);
1360        assert_eq!(task_id, "task-fix-login-bug");
1361
1362        let node = g.get_node(&task_id).unwrap();
1363        assert_eq!(node.title, "Fix login bug");
1364        assert_eq!(node.node_type.as_deref(), Some("task"));
1365        assert_eq!(node.status, NodeStatus::Todo);
1366
1367        // No edges
1368        assert!(g.edges.is_empty());
1369    }
1370
1371    #[test]
1372    fn test_task_with_feature() {
1373        let mut g = Graph::new();
1374        // Create a feature first
1375        g.add_feature("Auth", &[]);
1376
1377        let task_id = g.add_task("Add OAuth", Some("feat-auth"), &[], &["backend".into()], Some(1));
1378        assert_eq!(task_id, "task-auth-add-oauth");
1379
1380        let node = g.get_node(&task_id).unwrap();
1381        assert_eq!(node.tags, vec!["backend".to_string()]);
1382        assert_eq!(node.priority, Some(1));
1383
1384        // implements edge
1385        assert!(g.edges.iter().any(|e| {
1386            e.from == "task-auth-add-oauth" && e.to == "feat-auth" && e.relation == "implements"
1387        }));
1388    }
1389
1390    #[test]
1391    fn test_task_with_deps() {
1392        let mut g = Graph::new();
1393        // Create some nodes to depend on
1394        g.add_node(Node::new("task-setup", "Setup Environment"));
1395        g.add_node(Node::new("task-config", "Write Config"));
1396
1397        let task_id = g.add_task("Deploy App", None, &["task-setup".into(), "Write Config".into()], &[], None);
1398        assert_eq!(task_id, "task-deploy-app");
1399
1400        // depends_on edges
1401        let deps: Vec<_> = g.edges.iter()
1402            .filter(|e| e.from == "task-deploy-app" && e.relation == "depends_on")
1403            .collect();
1404        assert_eq!(deps.len(), 2);
1405    }
1406}
1407
1408#[cfg(test)]
1409mod merge_feature_nodes_tests {
1410    use super::*;
1411
1412    #[test]
1413    fn test_basic_merge() {
1414        let mut g = Graph::new();
1415        // Create feature with tasks
1416        g.add_feature("Auth", &[
1417            TaskSpec { title: "Old Task 1".into(), status: None, tags: vec![], deps: vec![] },
1418            TaskSpec { title: "Old Task 2".into(), status: None, tags: vec![], deps: vec![] },
1419        ]);
1420
1421        // Build incoming graph with new tasks
1422        let mut incoming = Graph::new();
1423        incoming.add_node({
1424            let mut n = Node::new("new-task-a", "New Task A");
1425            n.node_type = Some("task".into());
1426            n
1427        });
1428        incoming.add_node({
1429            let mut n = Node::new("new-task-b", "New Task B");
1430            n.node_type = Some("task".into());
1431            n
1432        });
1433
1434        let (removed, added) = g.merge_feature_nodes("feat-auth", incoming);
1435        assert_eq!(removed, 2);
1436        assert_eq!(added, 2);
1437
1438        // Old tasks gone
1439        assert!(g.get_node("task-auth-old-task-1").is_none());
1440        assert!(g.get_node("task-auth-old-task-2").is_none());
1441
1442        // New tasks present
1443        assert!(g.get_node("new-task-a").is_some());
1444        assert!(g.get_node("new-task-b").is_some());
1445
1446        // Feature still exists
1447        assert!(g.get_node("feat-auth").is_some());
1448
1449        // New implements edges
1450        let implements: Vec<_> = g.edges.iter()
1451            .filter(|e| e.relation == "implements" && e.to == "feat-auth")
1452            .collect();
1453        assert_eq!(implements.len(), 2);
1454    }
1455
1456    #[test]
1457    fn test_edge_cascade() {
1458        let mut g = Graph::new();
1459        g.add_feature("Auth", &[
1460            TaskSpec { title: "Task X".into(), status: None, tags: vec![], deps: vec![] },
1461        ]);
1462
1463        // Add extra edge to the old task
1464        g.add_edge(Edge::new("task-auth-task-x", "some-other-node", "related_to"));
1465        g.add_node(Node::new("some-other-node", "Other"));
1466
1467        // Before merge: task-auth-task-x has edges
1468        assert!(g.edges.iter().any(|e| e.from == "task-auth-task-x"));
1469
1470        let (removed, _added) = g.merge_feature_nodes("feat-auth", Graph::new());
1471
1472        assert_eq!(removed, 1);
1473        // Old task and all its edges removed
1474        assert!(g.get_node("task-auth-task-x").is_none());
1475        assert!(!g.edges.iter().any(|e| e.from == "task-auth-task-x" || e.to == "task-auth-task-x"));
1476    }
1477
1478    #[test]
1479    fn test_empty_merge() {
1480        let mut g = Graph::new();
1481        g.add_feature("Auth", &[
1482            TaskSpec { title: "Task 1".into(), status: None, tags: vec![], deps: vec![] },
1483            TaskSpec { title: "Task 2".into(), status: None, tags: vec![], deps: vec![] },
1484        ]);
1485
1486        let (removed, added) = g.merge_feature_nodes("feat-auth", Graph::new());
1487        assert_eq!(removed, 2);
1488        assert_eq!(added, 0);
1489
1490        // Feature still exists
1491        assert!(g.get_node("feat-auth").is_some());
1492        // No implements edges left
1493        let implements: Vec<_> = g.edges.iter()
1494            .filter(|e| e.relation == "implements" && e.to == "feat-auth")
1495            .collect();
1496        assert_eq!(implements.len(), 0);
1497    }
1498
1499    #[test]
1500    fn test_edge_dedup_on_merge() {
1501        let mut g = Graph::new();
1502        g.add_feature("Auth", &[]);
1503
1504        let mut incoming = Graph::new();
1505        incoming.add_node({
1506            let mut n = Node::new("task-new", "New Task");
1507            n.node_type = Some("task".into());
1508            n
1509        });
1510
1511        // First merge
1512        g.merge_feature_nodes("feat-auth", incoming.clone());
1513
1514        // Second merge of same nodes (simulate re-merge)
1515        // Remove the node first so add_node can re-add it
1516        g.remove_node("task-new");
1517        let mut incoming2 = Graph::new();
1518        incoming2.add_node({
1519            let mut n = Node::new("task-new", "New Task");
1520            n.node_type = Some("task".into());
1521            n
1522        });
1523        g.merge_feature_nodes("feat-auth", incoming2);
1524
1525        // Should not have duplicate implements edges
1526        let implements: Vec<_> = g.edges.iter()
1527            .filter(|e| e.from == "task-new" && e.to == "feat-auth" && e.relation == "implements")
1528            .collect();
1529        assert_eq!(implements.len(), 1);
1530    }
1531
1532    #[test]
1533    fn test_infer_node_type_known_prefixes() {
1534        assert_eq!(infer_node_type("file:src/main.rs"), Some("file"));
1535        assert_eq!(infer_node_type("fn:my_func"), Some("function"));
1536        assert_eq!(infer_node_type("func:my_func"), Some("function"));
1537        assert_eq!(infer_node_type("struct:MyStruct"), Some("class"));
1538        assert_eq!(infer_node_type("class:MyClass"), Some("class"));
1539        assert_eq!(infer_node_type("mod:mymod"), Some("module"));
1540        assert_eq!(infer_node_type("module:mymod"), Some("module"));
1541        assert_eq!(infer_node_type("method:do_thing"), Some("method"));
1542        assert_eq!(infer_node_type("trait:MyTrait"), Some("trait"));
1543        assert_eq!(infer_node_type("interface:IFoo"), Some("trait"));
1544        assert_eq!(infer_node_type("enum:Color"), Some("enum"));
1545        assert_eq!(infer_node_type("const:MAX_SIZE"), Some("constant"));
1546        assert_eq!(infer_node_type("static:INSTANCE"), Some("constant"));
1547        assert_eq!(infer_node_type("test:test_foo"), Some("test"));
1548        assert_eq!(infer_node_type("impl:MyStruct"), Some("impl"));
1549    }
1550
1551    #[test]
1552    fn test_infer_node_type_unknown_prefix() {
1553        assert_eq!(infer_node_type("task-auth-login"), None);
1554        assert_eq!(infer_node_type("feat-pipeline"), None);
1555        assert_eq!(infer_node_type("random-id"), None);
1556        assert_eq!(infer_node_type(""), None);
1557    }
1558
1559    #[test]
1560    fn test_infer_node_type_no_colon() {
1561        // Without a colon the whole ID is the "prefix" — should not match known types
1562        // unless the ID itself happens to be e.g. "file" (unlikely but valid)
1563        assert_eq!(infer_node_type("file"), Some("file"));
1564        assert_eq!(infer_node_type("something"), None);
1565    }
1566
1567    #[test]
1568    fn test_add_node_auto_infers_type() {
1569        let mut g = Graph::new();
1570        let node = Node::new("fn:process_data", "Process Data");
1571        assert!(node.node_type.is_none()); // not set on Node::new
1572        g.add_node(node);
1573        let added = g.get_node("fn:process_data").unwrap();
1574        assert_eq!(added.node_type.as_deref(), Some("function"));
1575    }
1576
1577    #[test]
1578    fn test_add_node_does_not_override_explicit_type() {
1579        let mut g = Graph::new();
1580        let mut node = Node::new("fn:process_data", "Process Data");
1581        node.node_type = Some("custom".to_string());
1582        g.add_node(node);
1583        let added = g.get_node("fn:process_data").unwrap();
1584        assert_eq!(added.node_type.as_deref(), Some("custom"));
1585    }
1586
1587    #[test]
1588    fn test_add_node_no_infer_for_unknown_prefix() {
1589        let mut g = Graph::new();
1590        let node = Node::new("task-auth-login", "Login task");
1591        g.add_node(node);
1592        let added = g.get_node("task-auth-login").unwrap();
1593        assert!(added.node_type.is_none());
1594    }
1595}