Skip to main content

vibe_graph_core/
lib.rs

1//! Core domain types shared across the entire Vibe-Graph workspace.
2
3use petgraph::stable_graph::{NodeIndex, StableDiGraph};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime};
9
10// Use web-time for WASM (std::time::Instant panics in WASM)
11#[cfg(not(target_arch = "wasm32"))]
12use std::time::Instant;
13#[cfg(target_arch = "wasm32")]
14use web_time::Instant;
15
16// =============================================================================
17// Git Change Tracking Types
18// =============================================================================
19
20/// Type of change detected for a file in git.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum GitChangeKind {
23    /// File was modified (content changed)
24    Modified,
25    /// File was newly added (staged new file)
26    Added,
27    /// File was deleted
28    Deleted,
29    /// File was renamed (old path)
30    RenamedFrom,
31    /// File was renamed (new path)
32    RenamedTo,
33    /// File is untracked (new file not yet staged)
34    Untracked,
35}
36
37impl GitChangeKind {
38    /// Get a display label for the change kind.
39    pub fn label(&self) -> &'static str {
40        match self {
41            GitChangeKind::Modified => "Modified",
42            GitChangeKind::Added => "Added",
43            GitChangeKind::Deleted => "Deleted",
44            GitChangeKind::RenamedFrom => "Renamed (from)",
45            GitChangeKind::RenamedTo => "Renamed (to)",
46            GitChangeKind::Untracked => "Untracked",
47        }
48    }
49
50    /// Get a short symbol for the change kind.
51    pub fn symbol(&self) -> &'static str {
52        match self {
53            GitChangeKind::Modified => "M",
54            GitChangeKind::Added => "+",
55            GitChangeKind::Deleted => "-",
56            GitChangeKind::RenamedFrom => "R←",
57            GitChangeKind::RenamedTo => "R→",
58            GitChangeKind::Untracked => "?",
59        }
60    }
61}
62
63/// Represents a single file change detected in git.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct GitFileChange {
66    /// Relative path of the changed file.
67    pub path: PathBuf,
68    /// Kind of change.
69    pub kind: GitChangeKind,
70    /// Whether this is a staged change (vs working directory).
71    pub staged: bool,
72}
73
74/// Snapshot of git changes for an entire repository.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct GitChangeSnapshot {
77    /// All detected changes.
78    pub changes: Vec<GitFileChange>,
79    /// Timestamp when this snapshot was taken.
80    #[serde(skip)]
81    pub captured_at: Option<Instant>,
82}
83
84impl GitChangeSnapshot {
85    /// Create a new empty snapshot.
86    pub fn new() -> Self {
87        Self {
88            changes: Vec::new(),
89            captured_at: Some(Instant::now()),
90        }
91    }
92
93    /// Check if a path has any changes.
94    pub fn has_changes(&self, path: &Path) -> bool {
95        self.changes.iter().any(|c| c.path == path)
96    }
97
98    /// Get the change kind for a path, if any.
99    pub fn get_change(&self, path: &Path) -> Option<&GitFileChange> {
100        self.changes.iter().find(|c| c.path == path)
101    }
102
103    /// Get all paths that have changes.
104    pub fn changed_paths(&self) -> impl Iterator<Item = &Path> {
105        self.changes.iter().map(|c| c.path.as_path())
106    }
107
108    /// Count changes by kind.
109    pub fn count_by_kind(&self, kind: GitChangeKind) -> usize {
110        self.changes.iter().filter(|c| c.kind == kind).count()
111    }
112
113    /// Check if snapshot is stale (older than given duration).
114    pub fn is_stale(&self, max_age: Duration) -> bool {
115        match self.captured_at {
116            Some(at) => at.elapsed() > max_age,
117            None => true,
118        }
119    }
120
121    /// Get age of this snapshot.
122    pub fn age(&self) -> Option<Duration> {
123        self.captured_at.map(|at| at.elapsed())
124    }
125}
126
127/// State for animating change indicators.
128#[derive(Debug, Clone)]
129pub struct ChangeIndicatorState {
130    /// Animation phase (0.0 to 1.0, loops).
131    pub phase: f32,
132    /// Animation speed multiplier.
133    pub speed: f32,
134    /// Whether animation is enabled.
135    pub enabled: bool,
136}
137
138impl Default for ChangeIndicatorState {
139    fn default() -> Self {
140        Self {
141            phase: 0.0,
142            speed: 1.0,
143            enabled: true,
144        }
145    }
146}
147
148impl ChangeIndicatorState {
149    /// Advance the animation by delta time.
150    pub fn tick(&mut self, dt: f32) {
151        if self.enabled {
152            self.phase = (self.phase + dt * self.speed) % 1.0;
153        }
154    }
155
156    /// Get the current pulse scale (1.0 to 1.3).
157    pub fn pulse_scale(&self) -> f32 {
158        // Smooth sine-based pulse
159        let t = self.phase * std::f32::consts::TAU;
160        1.0 + 0.15 * (t.sin() * 0.5 + 0.5)
161    }
162
163    /// Get the current alpha for outer ring (fades in/out).
164    pub fn ring_alpha(&self) -> f32 {
165        let t = self.phase * std::f32::consts::TAU;
166        0.3 + 0.4 * (t.sin() * 0.5 + 0.5)
167    }
168}
169
170/// Identifier for nodes within the `SourceCodeGraph`.
171#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172pub struct NodeId(pub u64);
173
174/// Identifier for edges within the `SourceCodeGraph`.
175#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176pub struct EdgeId(pub u64);
177
178/// Enumerates the kinds of nodes that can populate the `SourceCodeGraph`.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
180pub enum GraphNodeKind {
181    /// A logical module, typically aligned to a crate or package.
182    Module,
183    /// A discrete file in the repository.
184    File,
185    /// Directory or folder that contains additional nodes.
186    Directory,
187    /// A long-running service entry point.
188    Service,
189    /// Automated test suites or harnesses.
190    Test,
191    /// Any other kind that does not fit the curated list.
192    #[default]
193    Other,
194}
195
196/// Captures metadata for a node in the graph.
197#[derive(Debug, Default, Clone, Serialize, Deserialize)]
198pub struct GraphNode {
199    /// Unique identifier for this node.
200    pub id: NodeId,
201    /// Human readable name.
202    pub name: String,
203    /// Category associated with the node.
204    pub kind: GraphNodeKind,
205    /// Arbitrary metadata, e.g. language, path, ownership.
206    pub metadata: HashMap<String, String>,
207}
208
209/// Represents connections between graph nodes.
210#[derive(Debug, Default, Clone, Serialize, Deserialize)]
211pub struct GraphEdge {
212    /// Unique identifier for this edge.
213    pub id: EdgeId,
214    /// Originating node identifier.
215    pub from: NodeId,
216    /// Destination node identifier.
217    pub to: NodeId,
218    /// Description of the relationship ("imports", "calls", etc.).
219    pub relationship: String,
220    /// Arbitrary metadata for the relationship.
221    pub metadata: HashMap<String, String>,
222}
223
224/// Aggregate graph describing the full software project topology.
225#[derive(Debug, Default, Clone, Serialize, Deserialize)]
226pub struct SourceCodeGraph {
227    /// All nodes that make up the graph.
228    pub nodes: Vec<GraphNode>,
229    /// All edges that connect nodes in the graph.
230    pub edges: Vec<GraphEdge>,
231    /// Arbitrary metadata about the entire graph snapshot.
232    pub metadata: HashMap<String, String>,
233}
234
235impl SourceCodeGraph {
236    /// Creates an empty graph with no nodes or edges.
237    pub fn empty() -> Self {
238        Self::default()
239    }
240
241    /// Returns the number of nodes currently tracked.
242    pub fn node_count(&self) -> usize {
243        self.nodes.len()
244    }
245
246    /// Returns the number of edges currently tracked.
247    pub fn edge_count(&self) -> usize {
248        self.edges.len()
249    }
250
251    /// Convert to petgraph StableDiGraph for visualization/analysis.
252    /// Returns the graph and a mapping from NodeIndex to NodeId.
253    pub fn to_petgraph(&self) -> (StableDiGraph<GraphNode, String>, HashMap<NodeId, NodeIndex>) {
254        let mut graph = StableDiGraph::new();
255        let mut id_to_index = HashMap::new();
256
257        // Add all nodes
258        for node in &self.nodes {
259            let idx = graph.add_node(node.clone());
260            id_to_index.insert(node.id, idx);
261        }
262
263        // Add all edges
264        for edge in &self.edges {
265            if let (Some(&from_idx), Some(&to_idx)) =
266                (id_to_index.get(&edge.from), id_to_index.get(&edge.to))
267            {
268                graph.add_edge(from_idx, to_idx, edge.relationship.clone());
269            }
270        }
271
272        (graph, id_to_index)
273    }
274}
275
276/// Types of references detected between source files.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278pub enum ReferenceKind {
279    /// Rust `use` statement
280    Uses,
281    /// Python/JS/TS `import` statement
282    Imports,
283    /// Trait or interface implementation
284    Implements,
285    /// Filesystem hierarchy (parent->child)
286    Contains,
287}
288
289impl std::fmt::Display for ReferenceKind {
290    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
291        match self {
292            ReferenceKind::Uses => write!(f, "uses"),
293            ReferenceKind::Imports => write!(f, "imports"),
294            ReferenceKind::Implements => write!(f, "implements"),
295            ReferenceKind::Contains => write!(f, "contains"),
296        }
297    }
298}
299
300/// A detected reference from one source to another.
301#[derive(Debug, Clone)]
302pub struct SourceReference {
303    /// Path of the source file containing the reference
304    pub source_path: PathBuf,
305    /// Type of reference
306    pub kind: ReferenceKind,
307    /// Target path (may be partial, resolved later)
308    pub target_route: PathBuf,
309}
310
311/// Builder for constructing a `SourceCodeGraph` from project data.
312#[derive(Debug, Default)]
313pub struct SourceCodeGraphBuilder {
314    nodes: Vec<GraphNode>,
315    edges: Vec<GraphEdge>,
316    path_to_node: HashMap<PathBuf, NodeId>,
317    next_node_id: u64,
318    next_edge_id: u64,
319    metadata: HashMap<String, String>,
320}
321
322impl SourceCodeGraphBuilder {
323    /// Create a new builder.
324    pub fn new() -> Self {
325        Self::default()
326    }
327
328    /// Set metadata for the graph.
329    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
330        self.metadata.insert(key.into(), value.into());
331        self
332    }
333
334    /// Add a directory node.
335    pub fn add_directory(&mut self, path: &Path) -> NodeId {
336        if let Some(&id) = self.path_to_node.get(path) {
337            return id;
338        }
339
340        let id = NodeId(self.next_node_id);
341        self.next_node_id += 1;
342
343        let name = path
344            .file_name()
345            .and_then(|n| n.to_str())
346            .unwrap_or_else(|| path.to_str().unwrap_or("."))
347            .to_string();
348
349        let mut metadata = HashMap::new();
350        metadata.insert("path".to_string(), path.to_string_lossy().to_string());
351
352        self.nodes.push(GraphNode {
353            id,
354            name,
355            kind: GraphNodeKind::Directory,
356            metadata,
357        });
358
359        self.path_to_node.insert(path.to_path_buf(), id);
360        id
361    }
362
363    /// Add a file node with optional language detection.
364    pub fn add_file(&mut self, path: &Path, relative_path: &str) -> NodeId {
365        if let Some(&id) = self.path_to_node.get(path) {
366            return id;
367        }
368
369        let id = NodeId(self.next_node_id);
370        self.next_node_id += 1;
371
372        let name = path
373            .file_name()
374            .and_then(|n| n.to_str())
375            .unwrap_or_else(|| path.to_str().unwrap_or("unknown"))
376            .to_string();
377
378        // Determine kind based on extension
379        let kind = match path.extension().and_then(|e| e.to_str()) {
380            Some("rs") | Some("py") | Some("js") | Some("ts") | Some("tsx") | Some("jsx")
381            | Some("go") | Some("java") | Some("c") | Some("cpp") | Some("h") | Some("hpp") => {
382                if relative_path.contains("test") || name.starts_with("test_") {
383                    GraphNodeKind::Test
384                } else if name == "mod.rs"
385                    || name == "__init__.py"
386                    || name == "index.ts"
387                    || name == "index.js"
388                {
389                    GraphNodeKind::Module
390                } else {
391                    GraphNodeKind::File
392                }
393            }
394            _ => GraphNodeKind::File,
395        };
396
397        let mut metadata = HashMap::new();
398        metadata.insert("path".to_string(), path.to_string_lossy().to_string());
399        metadata.insert("relative_path".to_string(), relative_path.to_string());
400
401        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
402            metadata.insert("extension".to_string(), ext.to_string());
403            metadata.insert(
404                "language".to_string(),
405                extension_to_language(ext).to_string(),
406            );
407        }
408
409        self.nodes.push(GraphNode {
410            id,
411            name,
412            kind,
413            metadata,
414        });
415
416        self.path_to_node.insert(path.to_path_buf(), id);
417        id
418    }
419
420    /// Add a hierarchy edge (parent contains child).
421    pub fn add_hierarchy_edge(&mut self, parent_path: &Path, child_path: &Path) {
422        if let (Some(&parent_id), Some(&child_id)) = (
423            self.path_to_node.get(parent_path),
424            self.path_to_node.get(child_path),
425        ) {
426            if parent_id != child_id {
427                self.add_edge(parent_id, child_id, ReferenceKind::Contains);
428            }
429        }
430    }
431
432    /// Add an edge between two nodes.
433    pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: ReferenceKind) {
434        let id = EdgeId(self.next_edge_id);
435        self.next_edge_id += 1;
436
437        self.edges.push(GraphEdge {
438            id,
439            from,
440            to,
441            relationship: kind.to_string(),
442            metadata: HashMap::new(),
443        });
444    }
445
446    /// Get NodeId for a path if it exists.
447    pub fn get_node_id(&self, path: &Path) -> Option<NodeId> {
448        self.path_to_node.get(path).copied()
449    }
450
451    /// Find a node by matching path suffix (for reference resolution).
452    pub fn find_node_by_path_suffix(&self, route: &Path) -> Option<NodeId> {
453        let route_str = route.to_string_lossy();
454
455        for (path, &node_id) in &self.path_to_node {
456            let path_str = path.to_string_lossy();
457
458            // Strategy 1: Direct suffix match
459            if path_str.ends_with(route_str.as_ref()) {
460                return Some(node_id);
461            }
462
463            // Strategy 2: Normalized comparison
464            let normalized_path: String = path_str.trim_start_matches("./").replace('\\', "/");
465            let normalized_route: String = route_str.trim_start_matches("./").replace('\\', "/");
466            if normalized_path.ends_with(&normalized_route) {
467                return Some(node_id);
468            }
469
470            // Strategy 3: Module path matching (e.g., core/models.rs -> src/core/models.rs)
471            let route_parts: Vec<&str> = normalized_route.split('/').collect();
472            let path_parts: Vec<&str> = normalized_path.split('/').collect();
473            if route_parts.len() <= path_parts.len() {
474                for window in path_parts.windows(route_parts.len()) {
475                    if window == route_parts.as_slice() {
476                        return Some(node_id);
477                    }
478                }
479            }
480
481            // Strategy 4: Filename match
482            if let (Some(file_name), Some(route_name)) = (
483                path.file_name().and_then(|n| n.to_str()),
484                route.file_name().and_then(|n| n.to_str()),
485            ) {
486                if file_name == route_name {
487                    return Some(node_id);
488                }
489            }
490        }
491
492        None
493    }
494
495    /// Set a metadata key on an existing node.
496    pub fn set_node_metadata(
497        &mut self,
498        node_id: NodeId,
499        key: impl Into<String>,
500        value: impl Into<String>,
501    ) {
502        if let Some(node) = self.nodes.iter_mut().find(|n| n.id == node_id) {
503            node.metadata.insert(key.into(), value.into());
504        }
505    }
506
507    /// Get the current node count.
508    pub fn node_count(&self) -> usize {
509        self.nodes.len()
510    }
511
512    /// Get the current edge count.
513    pub fn edge_count(&self) -> usize {
514        self.edges.len()
515    }
516
517    /// Build the final `SourceCodeGraph`.
518    pub fn build(self) -> SourceCodeGraph {
519        SourceCodeGraph {
520            nodes: self.nodes,
521            edges: self.edges,
522            metadata: self.metadata,
523        }
524    }
525}
526
527/// Detect references in Rust source code.
528pub fn detect_rust_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
529    let mut refs = Vec::new();
530
531    for line in content.lines() {
532        let trimmed = line.trim();
533
534        // Handle 'mod' declarations
535        if trimmed.starts_with("pub mod ") || trimmed.starts_with("mod ") {
536            let mod_part = trimmed
537                .strip_prefix("pub mod ")
538                .or_else(|| trimmed.strip_prefix("mod "))
539                .unwrap_or("")
540                .split(';')
541                .next()
542                .unwrap_or("")
543                .trim();
544
545            if !mod_part.is_empty() && !mod_part.contains('{') {
546                refs.push(SourceReference {
547                    source_path: source_path.to_path_buf(),
548                    kind: ReferenceKind::Uses,
549                    target_route: PathBuf::from(format!("{}.rs", mod_part)),
550                });
551                refs.push(SourceReference {
552                    source_path: source_path.to_path_buf(),
553                    kind: ReferenceKind::Uses,
554                    target_route: PathBuf::from(format!("{}/mod.rs", mod_part)),
555                });
556            }
557        }
558
559        if !trimmed.starts_with("use ") {
560            continue;
561        }
562
563        // Extract module path from use statement
564        let use_part = trimmed
565            .strip_prefix("use ")
566            .unwrap_or("")
567            .split(';')
568            .next()
569            .unwrap_or("")
570            .split('{')
571            .next()
572            .unwrap_or("")
573            .trim();
574
575        if use_part.is_empty() {
576            continue;
577        }
578
579        // Propose everything that looks like a path.
580        // The graph builder will filter out references that don't resolve to actual nodes.
581        let module_path = use_part
582            .strip_prefix("crate::")
583            .or_else(|| use_part.strip_prefix("self::"))
584            .or_else(|| use_part.strip_prefix("super::"))
585            .unwrap_or(use_part);
586
587        // Convert module path to file path
588        let path_str = module_path
589            .replace("::", "/")
590            .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_')
591            .to_string();
592
593        refs.push(SourceReference {
594            source_path: source_path.to_path_buf(),
595            kind: ReferenceKind::Uses,
596            target_route: PathBuf::from(format!("{}.rs", path_str)),
597        });
598    }
599
600    refs
601}
602
603/// Detect references in Python source code.
604pub fn detect_python_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
605    let mut refs = Vec::new();
606
607    for line in content.lines() {
608        let trimmed = line.trim();
609
610        // Match "import module" statements
611        if trimmed.starts_with("import ") && !trimmed.starts_with("import(") {
612            let import_part = trimmed
613                .strip_prefix("import ")
614                .unwrap_or("")
615                .split_whitespace()
616                .next()
617                .unwrap_or("")
618                .split(',')
619                .next()
620                .unwrap_or("")
621                .trim();
622
623            if !import_part.is_empty() {
624                let path_str = import_part.replace('.', "/");
625                refs.push(SourceReference {
626                    source_path: source_path.to_path_buf(),
627                    kind: ReferenceKind::Imports,
628                    target_route: PathBuf::from(format!("{}.py", path_str)),
629                });
630            }
631        }
632
633        // Match "from module import something" statements
634        if let Some(module_part) = trimmed
635            .strip_prefix("from ")
636            .and_then(|s| s.split(" import ").next())
637        {
638            let module = module_part.trim();
639            if !module.is_empty() && module != "." && !module.starts_with("..") {
640                let path_str = module.replace('.', "/");
641                refs.push(SourceReference {
642                    source_path: source_path.to_path_buf(),
643                    kind: ReferenceKind::Imports,
644                    target_route: PathBuf::from(format!("{}.py", path_str)),
645                });
646            }
647        }
648    }
649
650    refs
651}
652
653/// Detect references in TypeScript/JavaScript source code.
654pub fn detect_ts_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
655    let mut refs = Vec::new();
656
657    for line in content.lines() {
658        let trimmed = line.trim();
659
660        // Match import statements: import X from 'path' or import 'path'
661        if trimmed.starts_with("import ") {
662            // Extract path from quotes
663            if let Some(path_start) = trimmed.find(['\'', '"']) {
664                let quote_char = trimmed.chars().nth(path_start).unwrap();
665                let rest = &trimmed[path_start + 1..];
666                if let Some(path_end) = rest.find(quote_char) {
667                    let import_path = &rest[..path_end];
668                    // Only track relative imports
669                    if import_path.starts_with('.') {
670                        refs.push(SourceReference {
671                            source_path: source_path.to_path_buf(),
672                            kind: ReferenceKind::Imports,
673                            target_route: PathBuf::from(import_path),
674                        });
675                    }
676                }
677            }
678        }
679    }
680
681    refs
682}
683
684/// Detect references in Lean 4 source code.
685///
686/// Handles `import` statements (e.g. `import Mathlib.Topology.Basic`)
687/// and `open` namespace declarations.
688pub fn detect_lean_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
689    let mut refs = Vec::new();
690
691    for line in content.lines() {
692        let trimmed = line.trim();
693
694        // Skip comments and empty lines
695        if trimmed.is_empty() || trimmed.starts_with("--") || trimmed.starts_with("/-") {
696            continue;
697        }
698
699        // `import Mathlib.Topology.Basic` or `public import Mathlib.Foo.Bar`
700        let import_part = trimmed
701            .strip_prefix("public import ")
702            .or_else(|| trimmed.strip_prefix("import "));
703
704        if let Some(module_path) = import_part {
705            let module = module_path.split_whitespace().next().unwrap_or("").trim();
706
707            if !module.is_empty() {
708                let file_path = module.replace('.', "/");
709                refs.push(SourceReference {
710                    source_path: source_path.to_path_buf(),
711                    kind: ReferenceKind::Imports,
712                    target_route: PathBuf::from(format!("{}.lean", file_path)),
713                });
714            }
715            continue;
716        }
717
718        // `open Topology Filter in` or `open Topology`
719        // These represent namespace dependencies worth capturing.
720        if let Some(rest) = trimmed.strip_prefix("open ") {
721            let namespaces = rest.split(" in").next().unwrap_or(rest).split_whitespace();
722
723            for ns in namespaces {
724                let ns = ns.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '.');
725                if ns.is_empty() || ns == "scoped" {
726                    continue;
727                }
728                let file_path = ns.replace('.', "/");
729                refs.push(SourceReference {
730                    source_path: source_path.to_path_buf(),
731                    kind: ReferenceKind::Uses,
732                    target_route: PathBuf::from(format!("{}.lean", file_path)),
733                });
734            }
735        }
736    }
737
738    refs
739}
740
741/// Detect references based on file extension.
742pub fn detect_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
743    match source_path.extension().and_then(|e| e.to_str()) {
744        Some("rs") => detect_rust_references(content, source_path),
745        Some("py") => detect_python_references(content, source_path),
746        Some("ts") | Some("tsx") | Some("js") | Some("jsx") => {
747            detect_ts_references(content, source_path)
748        }
749        Some("lean") => detect_lean_references(content, source_path),
750        _ => Vec::new(),
751    }
752}
753
754/// Map file extension to language name.
755fn extension_to_language(ext: &str) -> &'static str {
756    match ext {
757        "rs" => "rust",
758        "py" => "python",
759        "js" => "javascript",
760        "ts" => "typescript",
761        "tsx" => "typescript",
762        "jsx" => "javascript",
763        "go" => "go",
764        "java" => "java",
765        "lean" => "lean",
766        "c" | "h" => "c",
767        "cpp" | "hpp" | "cc" | "cxx" => "cpp",
768        "md" => "markdown",
769        "json" => "json",
770        "yaml" | "yml" => "yaml",
771        "toml" => "toml",
772        _ => "unknown",
773    }
774}
775
776/// Represents an explicitly declared vibe (intent/spec/decision) attached to graph regions.
777#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct Vibe {
779    /// Stable identifier for referencing the vibe.
780    pub id: String,
781    /// Short title summarizing the intent.
782    pub title: String,
783    /// Richer description with context.
784    pub description: String,
785    /// Target nodes within the graph impacted by the vibe.
786    pub targets: Vec<NodeId>,
787    /// Actor (human or machine) responsible for the vibe.
788    pub created_by: String,
789    /// Timestamp when the vibe entered the system.
790    pub created_at: SystemTime,
791    /// Optional tags or attributes.
792    pub metadata: HashMap<String, String>,
793}
794
795/// Canonical definition of governing rules in effect for a graph.
796#[derive(Debug, Default, Clone, Serialize, Deserialize)]
797pub struct Constitution {
798    /// Unique name of the constitution.
799    pub name: String,
800    /// Semantic version or revision identifier.
801    pub version: String,
802    /// Human-readable description of the guardrails.
803    pub description: String,
804    /// Simple list of policies; future versions may embed richer data.
805    pub policies: Vec<String>,
806}
807
808/// Generic payload that cells in the automaton can store.
809pub type StatePayload = Value;
810
811/// Captures the state of an individual cell in the LLM cellular automaton.
812#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct CellState {
814    /// Node the cell is associated with.
815    pub node_id: NodeId,
816    /// Arbitrary structured state payload.
817    pub payload: StatePayload,
818    /// Tracking field for energy, confidence, or strength indicators.
819    pub activation: f32,
820    /// Timestamp for the most recent update.
821    pub last_updated: SystemTime,
822    /// Free-form annotations (signals, metrics, citations, etc.).
823    pub annotations: HashMap<String, String>,
824}
825
826impl CellState {
827    /// Creates a fresh `CellState` wrapping the provided payload.
828    pub fn new(node_id: NodeId, payload: StatePayload) -> Self {
829        Self {
830            node_id,
831            payload,
832            activation: 0.0,
833            last_updated: SystemTime::now(),
834            annotations: HashMap::new(),
835        }
836    }
837}
838
839/// Represents a snapshot of the entire system ready to be fossilized in Git.
840#[derive(Debug, Clone, Serialize, Deserialize)]
841pub struct Snapshot {
842    /// Identifier suitable for referencing the snapshot in storage.
843    pub id: String,
844    /// Captured graph at the time of snapshot.
845    pub graph: SourceCodeGraph,
846    /// All vibes considered part of the snapshot.
847    pub vibes: Vec<Vibe>,
848    /// Cell states for the automaton corresponding to the snapshot.
849    pub cell_states: Vec<CellState>,
850    /// Constitution in effect when the snapshot was created.
851    pub constitution: Constitution,
852    /// Timestamp for when the snapshot was recorded.
853    pub created_at: SystemTime,
854}
855
856/// Strategies for mapping a logical graph to a filesystem hierarchy.
857#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
858pub enum LayoutStrategy {
859    /// Everything in one directory (flat).
860    #[default]
861    Flat,
862    /// Spatial organization for lattice-like graphs (rows/cols).
863    Lattice { width: usize, group_by_row: bool },
864    /// Direct mapping (trusts existing paths or uses heuristics).
865    Direct,
866    /// Preserves existing directory structure (Identity).
867    Preserve,
868    /// Modular clustering (auto-detected modules).
869    Modular,
870}
871
872// =============================================================================
873// Sampler Abstraction
874// =============================================================================
875//
876// A Sampler generalizes the pattern of:  Select(nodes) → Compute(local_fn) → Emit(artifact)
877//
878// Existing operations (automaton Rules, impact_analysis, evolution planning)
879// are all special cases. The Sampler trait makes the pattern explicit, composable,
880// and reusable across native and WASM targets.
881
882/// Local context provided to a [`Sampler`] during computation.
883///
884/// Gathers everything known about a single node at the time of sampling:
885/// structural position, optional content, previously-computed annotations,
886/// and the edges connecting it to its neighbors.
887#[derive(Debug, Clone)]
888pub struct SampleContext<'a> {
889    /// The node being sampled.
890    pub node: &'a GraphNode,
891    /// Direct neighbors (both incoming and outgoing edges resolved to nodes).
892    pub neighbors: Vec<NeighborRef<'a>>,
893    /// Source file content, when available and requested.
894    pub content: Option<&'a str>,
895    /// Previously-computed artifacts attached to this node (keyed by sampler id).
896    /// Enables sampler composition: earlier samplers deposit artifacts that
897    /// later samplers can read.
898    pub annotations: &'a HashMap<String, Value>,
899    /// Graph-level metadata (project name, workspace root, etc.).
900    pub graph_metadata: &'a HashMap<String, String>,
901}
902
903/// A neighbor node together with the edge that connects it.
904#[derive(Debug, Clone)]
905pub struct NeighborRef<'a> {
906    /// The neighboring node.
907    pub node: &'a GraphNode,
908    /// The connecting edge (direction implied by the edge's from/to fields).
909    pub edge: &'a GraphEdge,
910}
911
912/// Typed output produced by a sampler for one node.
913#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SampleArtifact {
915    /// Which node this artifact belongs to.
916    pub node_id: NodeId,
917    /// Structured payload (schema depends on the sampler).
918    pub value: Value,
919}
920
921/// Collected output of a full sampling pass.
922#[derive(Debug, Clone, Default, Serialize, Deserialize)]
923pub struct SampleResult {
924    /// Identifier of the sampler that produced these artifacts.
925    pub sampler_id: String,
926    /// Per-node artifacts, ordered by the sampler's iteration order.
927    pub artifacts: Vec<SampleArtifact>,
928    /// Aggregate / summary metadata for the entire pass.
929    pub metadata: HashMap<String, Value>,
930}
931
932impl SampleResult {
933    /// Look up the artifact for a specific node.
934    pub fn get(&self, node_id: NodeId) -> Option<&SampleArtifact> {
935        self.artifacts.iter().find(|a| a.node_id == node_id)
936    }
937
938    /// Number of artifacts produced.
939    pub fn len(&self) -> usize {
940        self.artifacts.len()
941    }
942
943    /// Whether no artifacts were produced.
944    pub fn is_empty(&self) -> bool {
945        self.artifacts.is_empty()
946    }
947
948    /// Iterate over (NodeId, &Value) pairs.
949    pub fn iter(&self) -> impl Iterator<Item = (NodeId, &Value)> {
950        self.artifacts.iter().map(|a| (a.node_id, &a.value))
951    }
952}
953
954/// Determines which nodes a sampler should operate on.
955#[derive(Default)]
956pub enum NodeSelector {
957    /// Sample every node in the graph.
958    #[default]
959    All,
960    /// Only nodes whose kind matches.
961    ByKind(GraphNodeKind),
962    /// Only nodes with these specific IDs.
963    Explicit(Vec<NodeId>),
964    /// Only nodes whose metadata contains the given key.
965    HasMetadata(String),
966    /// Custom predicate (not serializable — use for in-process composition).
967    Predicate(Box<dyn Fn(&GraphNode) -> bool + Send + Sync>),
968}
969
970impl std::fmt::Debug for NodeSelector {
971    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
972        match self {
973            NodeSelector::All => write!(f, "All"),
974            NodeSelector::ByKind(k) => write!(f, "ByKind({:?})", k),
975            NodeSelector::Explicit(ids) => write!(f, "Explicit({:?})", ids),
976            NodeSelector::HasMetadata(key) => write!(f, "HasMetadata({:?})", key),
977            NodeSelector::Predicate(_) => write!(f, "Predicate(<fn>)"),
978        }
979    }
980}
981
982impl NodeSelector {
983    /// Test whether a node passes this selector.
984    pub fn matches(&self, node: &GraphNode) -> bool {
985        match self {
986            NodeSelector::All => true,
987            NodeSelector::ByKind(kind) => node.kind == *kind,
988            NodeSelector::Explicit(ids) => ids.contains(&node.id),
989            NodeSelector::HasMetadata(key) => node.metadata.contains_key(key),
990            NodeSelector::Predicate(f) => f(node),
991        }
992    }
993}
994
995/// Per-node annotation map produced and consumed by [`Sampler`] stages.
996pub type AnnotationMap = HashMap<NodeId, HashMap<String, Value>>;
997
998/// The core sampling primitive.
999///
1000/// A sampler selects nodes from a graph, computes a local function for each,
1001/// and collects the results into a [`SampleResult`]. Samplers are composable:
1002/// the output of one can be fed into the `annotations` of the next via
1003/// [`SamplerPipeline`].
1004pub trait Sampler: Send + Sync {
1005    /// Stable identifier (used as key in annotation maps and persistence).
1006    fn id(&self) -> &str;
1007
1008    /// Which nodes this sampler operates on.
1009    fn selector(&self) -> NodeSelector {
1010        NodeSelector::All
1011    }
1012
1013    /// Compute the artifact for a single node.
1014    /// Return `Ok(None)` to skip a node without error.
1015    fn compute(&self, ctx: &SampleContext<'_>) -> Result<Option<Value>, SamplerError>;
1016
1017    /// Run the full sampling pass over a graph.
1018    ///
1019    /// Default implementation: select → build context → compute → collect.
1020    /// Override only when the sampler needs batch-level optimizations
1021    /// (e.g. batched embedding inference).
1022    fn sample(
1023        &self,
1024        graph: &SourceCodeGraph,
1025        annotations: &AnnotationMap,
1026    ) -> Result<SampleResult, SamplerError> {
1027        let selector = self.selector();
1028        let selected: Vec<&GraphNode> =
1029            graph.nodes.iter().filter(|n| selector.matches(n)).collect();
1030
1031        let mut artifacts = Vec::with_capacity(selected.len());
1032
1033        for node in &selected {
1034            let neighbors = graph.neighbors(node.id);
1035            let empty = HashMap::new();
1036            let node_annotations = annotations.get(&node.id).unwrap_or(&empty);
1037
1038            let ctx = SampleContext {
1039                node,
1040                neighbors,
1041                content: None,
1042                annotations: node_annotations,
1043                graph_metadata: &graph.metadata,
1044            };
1045
1046            if let Some(value) = self.compute(&ctx)? {
1047                artifacts.push(SampleArtifact {
1048                    node_id: node.id,
1049                    value,
1050                });
1051            }
1052        }
1053
1054        Ok(SampleResult {
1055            sampler_id: self.id().to_string(),
1056            artifacts,
1057            metadata: HashMap::new(),
1058        })
1059    }
1060}
1061
1062/// Errors that can occur during sampling.
1063#[derive(Debug, Clone)]
1064pub struct SamplerError {
1065    pub sampler_id: String,
1066    pub message: String,
1067}
1068
1069impl std::fmt::Display for SamplerError {
1070    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1071        write!(f, "sampler '{}': {}", self.sampler_id, self.message)
1072    }
1073}
1074
1075impl std::error::Error for SamplerError {}
1076
1077impl SamplerError {
1078    pub fn new(sampler_id: impl Into<String>, message: impl Into<String>) -> Self {
1079        Self {
1080            sampler_id: sampler_id.into(),
1081            message: message.into(),
1082        }
1083    }
1084}
1085
1086/// Chains multiple samplers so each one's output enriches the annotations
1087/// available to the next.
1088pub struct SamplerPipeline {
1089    stages: Vec<Box<dyn Sampler>>,
1090}
1091
1092impl SamplerPipeline {
1093    pub fn new() -> Self {
1094        Self { stages: Vec::new() }
1095    }
1096
1097    /// Append a sampler stage to the pipeline.
1098    pub fn with_stage(mut self, sampler: Box<dyn Sampler>) -> Self {
1099        self.stages.push(sampler);
1100        self
1101    }
1102
1103    /// Execute all stages in order, threading annotations forward.
1104    /// Returns the per-stage results and the accumulated annotation map.
1105    pub fn run(
1106        &self,
1107        graph: &SourceCodeGraph,
1108    ) -> Result<(Vec<SampleResult>, AnnotationMap), SamplerError> {
1109        let mut annotations: AnnotationMap = HashMap::new();
1110        let mut results = Vec::with_capacity(self.stages.len());
1111
1112        for stage in &self.stages {
1113            let result = stage.sample(graph, &annotations)?;
1114
1115            for artifact in &result.artifacts {
1116                annotations
1117                    .entry(artifact.node_id)
1118                    .or_default()
1119                    .insert(result.sampler_id.clone(), artifact.value.clone());
1120            }
1121
1122            results.push(result);
1123        }
1124
1125        Ok((results, annotations))
1126    }
1127}
1128
1129impl Default for SamplerPipeline {
1130    fn default() -> Self {
1131        Self::new()
1132    }
1133}
1134
1135// -- Helper: graph neighborhood lookup used by the default Sampler::sample --
1136
1137impl SourceCodeGraph {
1138    /// Collect direct neighbors of a node (both directions) with their edges.
1139    pub fn neighbors(&self, node_id: NodeId) -> Vec<NeighborRef<'_>> {
1140        let node_map: HashMap<NodeId, &GraphNode> = self.nodes.iter().map(|n| (n.id, n)).collect();
1141
1142        self.edges
1143            .iter()
1144            .filter_map(|edge| {
1145                let peer_id = if edge.from == node_id {
1146                    Some(edge.to)
1147                } else if edge.to == node_id {
1148                    Some(edge.from)
1149                } else {
1150                    None
1151                };
1152                peer_id.and_then(|pid| {
1153                    node_map.get(&pid).map(|peer_node| NeighborRef {
1154                        node: peer_node,
1155                        edge,
1156                    })
1157                })
1158            })
1159            .collect()
1160    }
1161}
1162
1163/// A sampler that produces no artifacts — useful as a pipeline placeholder
1164/// and for testing.
1165pub struct NoOpSampler;
1166
1167impl Sampler for NoOpSampler {
1168    fn id(&self) -> &str {
1169        "noop"
1170    }
1171
1172    fn compute(&self, _ctx: &SampleContext<'_>) -> Result<Option<Value>, SamplerError> {
1173        Ok(None)
1174    }
1175}
1176
1177/// A sampler that counts each node's direct neighbors (degree centrality).
1178/// Demonstrates the pattern and is useful as a lightweight structural signal.
1179pub struct DegreeSampler;
1180
1181impl Sampler for DegreeSampler {
1182    fn id(&self) -> &str {
1183        "degree"
1184    }
1185
1186    fn selector(&self) -> NodeSelector {
1187        NodeSelector::ByKind(GraphNodeKind::File)
1188    }
1189
1190    fn compute(&self, ctx: &SampleContext<'_>) -> Result<Option<Value>, SamplerError> {
1191        let incoming = ctx
1192            .neighbors
1193            .iter()
1194            .filter(|n| n.edge.to == ctx.node.id)
1195            .count();
1196        let outgoing = ctx
1197            .neighbors
1198            .iter()
1199            .filter(|n| n.edge.from == ctx.node.id)
1200            .count();
1201
1202        Ok(Some(serde_json::json!({
1203            "in": incoming,
1204            "out": outgoing,
1205            "total": incoming + outgoing,
1206        })))
1207    }
1208}
1209
1210/// A sampler that extracts metadata from nodes as-is, useful for exposing
1211/// node properties (language, extension, has_tests) into the annotation
1212/// pipeline without transformation.
1213pub struct MetadataSampler {
1214    keys: Vec<String>,
1215}
1216
1217impl MetadataSampler {
1218    /// Create a sampler that extracts the specified metadata keys.
1219    pub fn new(keys: Vec<String>) -> Self {
1220        Self { keys }
1221    }
1222
1223    /// Extract all available metadata.
1224    pub fn all() -> Self {
1225        Self { keys: Vec::new() }
1226    }
1227}
1228
1229impl Sampler for MetadataSampler {
1230    fn id(&self) -> &str {
1231        "metadata"
1232    }
1233
1234    fn compute(&self, ctx: &SampleContext<'_>) -> Result<Option<Value>, SamplerError> {
1235        let extracted: serde_json::Map<String, Value> = if self.keys.is_empty() {
1236            ctx.node
1237                .metadata
1238                .iter()
1239                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
1240                .collect()
1241        } else {
1242            self.keys
1243                .iter()
1244                .filter_map(|key| {
1245                    ctx.node
1246                        .metadata
1247                        .get(key)
1248                        .map(|v| (key.clone(), Value::String(v.clone())))
1249                })
1250                .collect()
1251        };
1252
1253        if extracted.is_empty() {
1254            Ok(None)
1255        } else {
1256            Ok(Some(Value::Object(extracted)))
1257        }
1258    }
1259}
1260
1261// =============================================================================
1262// Tests
1263// =============================================================================
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268    use serde_json::json;
1269
1270    fn test_graph() -> SourceCodeGraph {
1271        let mut meta_a = HashMap::new();
1272        meta_a.insert("relative_path".to_string(), "src/main.rs".to_string());
1273        meta_a.insert("extension".to_string(), "rs".to_string());
1274        meta_a.insert("language".to_string(), "rust".to_string());
1275
1276        let mut meta_b = HashMap::new();
1277        meta_b.insert("relative_path".to_string(), "src/lib.rs".to_string());
1278        meta_b.insert("extension".to_string(), "rs".to_string());
1279        meta_b.insert("language".to_string(), "rust".to_string());
1280
1281        let mut meta_dir = HashMap::new();
1282        meta_dir.insert("relative_path".to_string(), "src".to_string());
1283
1284        SourceCodeGraph {
1285            nodes: vec![
1286                GraphNode {
1287                    id: NodeId(0),
1288                    name: "src".to_string(),
1289                    kind: GraphNodeKind::Directory,
1290                    metadata: meta_dir,
1291                },
1292                GraphNode {
1293                    id: NodeId(1),
1294                    name: "main.rs".to_string(),
1295                    kind: GraphNodeKind::File,
1296                    metadata: meta_a,
1297                },
1298                GraphNode {
1299                    id: NodeId(2),
1300                    name: "lib.rs".to_string(),
1301                    kind: GraphNodeKind::Module,
1302                    metadata: meta_b,
1303                },
1304            ],
1305            edges: vec![
1306                GraphEdge {
1307                    id: EdgeId(0),
1308                    from: NodeId(0),
1309                    to: NodeId(1),
1310                    relationship: "contains".to_string(),
1311                    metadata: HashMap::new(),
1312                },
1313                GraphEdge {
1314                    id: EdgeId(1),
1315                    from: NodeId(0),
1316                    to: NodeId(2),
1317                    relationship: "contains".to_string(),
1318                    metadata: HashMap::new(),
1319                },
1320                GraphEdge {
1321                    id: EdgeId(2),
1322                    from: NodeId(1),
1323                    to: NodeId(2),
1324                    relationship: "uses".to_string(),
1325                    metadata: HashMap::new(),
1326                },
1327            ],
1328            metadata: {
1329                let mut m = HashMap::new();
1330                m.insert("name".to_string(), "test-project".to_string());
1331                m
1332            },
1333        }
1334    }
1335
1336    // -- SourceCodeGraph::neighbors --
1337
1338    #[test]
1339    fn test_neighbors_returns_both_directions() {
1340        let graph = test_graph();
1341        // Node 2 (lib.rs): contained by src (edge 1), used by main.rs (edge 2)
1342        let neighbors = graph.neighbors(NodeId(2));
1343        assert_eq!(neighbors.len(), 2);
1344
1345        let peer_ids: Vec<NodeId> = neighbors.iter().map(|n| n.node.id).collect();
1346        assert!(peer_ids.contains(&NodeId(0))); // src dir
1347        assert!(peer_ids.contains(&NodeId(1))); // main.rs
1348    }
1349
1350    #[test]
1351    fn test_neighbors_empty_for_unknown_node() {
1352        let graph = test_graph();
1353        let neighbors = graph.neighbors(NodeId(999));
1354        assert!(neighbors.is_empty());
1355    }
1356
1357    // -- NodeSelector --
1358
1359    #[test]
1360    fn test_selector_all() {
1361        let graph = test_graph();
1362        let sel = NodeSelector::All;
1363        assert!(graph.nodes.iter().all(|n| sel.matches(n)));
1364    }
1365
1366    #[test]
1367    fn test_selector_by_kind() {
1368        let graph = test_graph();
1369        let sel = NodeSelector::ByKind(GraphNodeKind::File);
1370        let matched: Vec<_> = graph.nodes.iter().filter(|n| sel.matches(n)).collect();
1371        assert_eq!(matched.len(), 1);
1372        assert_eq!(matched[0].name, "main.rs");
1373    }
1374
1375    #[test]
1376    fn test_selector_explicit() {
1377        let graph = test_graph();
1378        let sel = NodeSelector::Explicit(vec![NodeId(0), NodeId(2)]);
1379        let matched: Vec<_> = graph.nodes.iter().filter(|n| sel.matches(n)).collect();
1380        assert_eq!(matched.len(), 2);
1381    }
1382
1383    #[test]
1384    fn test_selector_has_metadata() {
1385        let graph = test_graph();
1386        let sel = NodeSelector::HasMetadata("language".to_string());
1387        let matched: Vec<_> = graph.nodes.iter().filter(|n| sel.matches(n)).collect();
1388        assert_eq!(matched.len(), 2); // main.rs and lib.rs, not src dir
1389    }
1390
1391    #[test]
1392    fn test_selector_predicate() {
1393        let graph = test_graph();
1394        let sel = NodeSelector::Predicate(Box::new(|n| n.name.ends_with(".rs")));
1395        let matched: Vec<_> = graph.nodes.iter().filter(|n| sel.matches(n)).collect();
1396        assert_eq!(matched.len(), 2);
1397    }
1398
1399    // -- NoOpSampler --
1400
1401    #[test]
1402    fn test_noop_sampler_produces_nothing() {
1403        let graph = test_graph();
1404        let sampler = NoOpSampler;
1405        let result = sampler.sample(&graph, &HashMap::new()).unwrap();
1406        assert!(result.is_empty());
1407        assert_eq!(result.sampler_id, "noop");
1408    }
1409
1410    // -- DegreeSampler --
1411
1412    #[test]
1413    fn test_degree_sampler() {
1414        let graph = test_graph();
1415        let sampler = DegreeSampler;
1416        let result = sampler.sample(&graph, &HashMap::new()).unwrap();
1417        assert_eq!(result.sampler_id, "degree");
1418
1419        // Only File-kind nodes are selected (main.rs = NodeId(1))
1420        assert_eq!(result.len(), 1);
1421        let artifact = result.get(NodeId(1)).unwrap();
1422        // main.rs: contained by src (in=1), uses lib.rs (out=1)
1423        assert_eq!(artifact.value["in"], 1);
1424        assert_eq!(artifact.value["out"], 1);
1425        assert_eq!(artifact.value["total"], 2);
1426    }
1427
1428    // -- MetadataSampler --
1429
1430    #[test]
1431    fn test_metadata_sampler_specific_keys() {
1432        let graph = test_graph();
1433        let sampler = MetadataSampler::new(vec!["language".to_string()]);
1434        let result = sampler.sample(&graph, &HashMap::new()).unwrap();
1435        // src dir has no "language" key → skipped
1436        assert_eq!(result.len(), 2);
1437        for (_, val) in result.iter() {
1438            assert_eq!(val["language"], "rust");
1439        }
1440    }
1441
1442    #[test]
1443    fn test_metadata_sampler_all_keys() {
1444        let graph = test_graph();
1445        let sampler = MetadataSampler::all();
1446        let result = sampler.sample(&graph, &HashMap::new()).unwrap();
1447        assert_eq!(result.len(), 3); // all nodes have at least relative_path
1448    }
1449
1450    // -- SampleResult --
1451
1452    #[test]
1453    fn test_sample_result_get_and_iter() {
1454        let result = SampleResult {
1455            sampler_id: "test".to_string(),
1456            artifacts: vec![
1457                SampleArtifact {
1458                    node_id: NodeId(1),
1459                    value: json!({"score": 0.9}),
1460                },
1461                SampleArtifact {
1462                    node_id: NodeId(2),
1463                    value: json!({"score": 0.5}),
1464                },
1465            ],
1466            metadata: HashMap::new(),
1467        };
1468        assert_eq!(result.len(), 2);
1469        assert!(!result.is_empty());
1470        assert_eq!(result.get(NodeId(1)).unwrap().value["score"], 0.9);
1471        assert!(result.get(NodeId(99)).is_none());
1472        assert_eq!(result.iter().count(), 2);
1473    }
1474
1475    // -- SamplerPipeline --
1476
1477    #[test]
1478    fn test_pipeline_threads_annotations() {
1479        let graph = test_graph();
1480        let pipeline = SamplerPipeline::new()
1481            .with_stage(Box::new(MetadataSampler::all()))
1482            .with_stage(Box::new(DegreeSampler));
1483
1484        let (results, annotations) = pipeline.run(&graph).unwrap();
1485        assert_eq!(results.len(), 2);
1486
1487        // main.rs (NodeId 1) should have annotations from both stages
1488        let main_annot = annotations.get(&NodeId(1)).unwrap();
1489        assert!(main_annot.contains_key("metadata"));
1490        assert!(main_annot.contains_key("degree"));
1491    }
1492
1493    #[test]
1494    fn test_pipeline_empty() {
1495        let graph = test_graph();
1496        let pipeline = SamplerPipeline::new();
1497        let (results, annotations) = pipeline.run(&graph).unwrap();
1498        assert!(results.is_empty());
1499        assert!(annotations.is_empty());
1500    }
1501
1502    // -- SamplerError --
1503
1504    #[test]
1505    fn test_sampler_error_display() {
1506        let err = SamplerError::new("embed", "model not loaded");
1507        assert_eq!(err.to_string(), "sampler 'embed': model not loaded");
1508    }
1509
1510    // -- Failing sampler --
1511
1512    struct FailingSampler;
1513    impl Sampler for FailingSampler {
1514        fn id(&self) -> &str {
1515            "failing"
1516        }
1517        fn compute(&self, _ctx: &SampleContext<'_>) -> Result<Option<Value>, SamplerError> {
1518            Err(SamplerError::new("failing", "intentional test failure"))
1519        }
1520    }
1521
1522    #[test]
1523    fn test_sampler_propagates_error() {
1524        let graph = test_graph();
1525        let sampler = FailingSampler;
1526        let result = sampler.sample(&graph, &HashMap::new());
1527        assert!(result.is_err());
1528        assert_eq!(result.unwrap_err().sampler_id, "failing");
1529    }
1530
1531    // -- Lean 4 reference detection --
1532
1533    #[test]
1534    fn test_detect_lean_references_imports() {
1535        let content = r#"/-
1536Copyright (c) 2024 Someone. All rights reserved.
1537-/
1538module
1539
1540public import Mathlib.Topology.ContinuousMap.Compact
1541public import Mathlib.Topology.MetricSpace.Ultra.Basic
1542import Aesop
1543
1544/-!
1545# Some module docs
1546-/
1547
1548open Topology Filter in
1549
1550def someDef := sorry
1551"#;
1552        let path = std::path::Path::new("Mathlib/Topology/Example.lean");
1553        let refs = detect_lean_references(content, path);
1554
1555        let import_targets: Vec<String> = refs
1556            .iter()
1557            .filter(|r| matches!(r.kind, ReferenceKind::Imports))
1558            .map(|r| r.target_route.to_string_lossy().to_string())
1559            .collect();
1560        assert_eq!(import_targets.len(), 3);
1561        assert!(import_targets.contains(&"Mathlib/Topology/ContinuousMap/Compact.lean".to_string()));
1562        assert!(
1563            import_targets.contains(&"Mathlib/Topology/MetricSpace/Ultra/Basic.lean".to_string())
1564        );
1565        assert!(import_targets.contains(&"Aesop.lean".to_string()));
1566
1567        let uses_targets: Vec<String> = refs
1568            .iter()
1569            .filter(|r| matches!(r.kind, ReferenceKind::Uses))
1570            .map(|r| r.target_route.to_string_lossy().to_string())
1571            .collect();
1572        assert_eq!(uses_targets.len(), 2);
1573        assert!(uses_targets.contains(&"Topology.lean".to_string()));
1574        assert!(uses_targets.contains(&"Filter.lean".to_string()));
1575    }
1576
1577    #[test]
1578    fn test_detect_lean_references_empty() {
1579        let content = "-- just a comment\ndef x := 42\n";
1580        let path = std::path::Path::new("test.lean");
1581        let refs = detect_lean_references(content, path);
1582        assert!(refs.is_empty());
1583    }
1584}