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, Instant, SystemTime};
9
10// =============================================================================
11// Git Change Tracking Types
12// =============================================================================
13
14/// Type of change detected for a file in git.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub enum GitChangeKind {
17    /// File was modified (content changed)
18    Modified,
19    /// File was newly added (untracked or staged new)
20    Added,
21    /// File was deleted
22    Deleted,
23    /// File was renamed (old path)
24    RenamedFrom,
25    /// File was renamed (new path)
26    RenamedTo,
27}
28
29impl GitChangeKind {
30    /// Get a display label for the change kind.
31    pub fn label(&self) -> &'static str {
32        match self {
33            GitChangeKind::Modified => "Modified",
34            GitChangeKind::Added => "Added",
35            GitChangeKind::Deleted => "Deleted",
36            GitChangeKind::RenamedFrom => "Renamed (from)",
37            GitChangeKind::RenamedTo => "Renamed (to)",
38        }
39    }
40
41    /// Get a short symbol for the change kind.
42    pub fn symbol(&self) -> &'static str {
43        match self {
44            GitChangeKind::Modified => "M",
45            GitChangeKind::Added => "+",
46            GitChangeKind::Deleted => "-",
47            GitChangeKind::RenamedFrom => "R←",
48            GitChangeKind::RenamedTo => "R→",
49        }
50    }
51}
52
53/// Represents a single file change detected in git.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct GitFileChange {
56    /// Relative path of the changed file.
57    pub path: PathBuf,
58    /// Kind of change.
59    pub kind: GitChangeKind,
60    /// Whether this is a staged change (vs working directory).
61    pub staged: bool,
62}
63
64/// Snapshot of git changes for an entire repository.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct GitChangeSnapshot {
67    /// All detected changes.
68    pub changes: Vec<GitFileChange>,
69    /// Timestamp when this snapshot was taken.
70    #[serde(skip)]
71    pub captured_at: Option<Instant>,
72}
73
74impl GitChangeSnapshot {
75    /// Create a new empty snapshot.
76    pub fn new() -> Self {
77        Self {
78            changes: Vec::new(),
79            captured_at: Some(Instant::now()),
80        }
81    }
82
83    /// Check if a path has any changes.
84    pub fn has_changes(&self, path: &Path) -> bool {
85        self.changes.iter().any(|c| c.path == path)
86    }
87
88    /// Get the change kind for a path, if any.
89    pub fn get_change(&self, path: &Path) -> Option<&GitFileChange> {
90        self.changes.iter().find(|c| c.path == path)
91    }
92
93    /// Get all paths that have changes.
94    pub fn changed_paths(&self) -> impl Iterator<Item = &Path> {
95        self.changes.iter().map(|c| c.path.as_path())
96    }
97
98    /// Count changes by kind.
99    pub fn count_by_kind(&self, kind: GitChangeKind) -> usize {
100        self.changes.iter().filter(|c| c.kind == kind).count()
101    }
102
103    /// Check if snapshot is stale (older than given duration).
104    pub fn is_stale(&self, max_age: Duration) -> bool {
105        match self.captured_at {
106            Some(at) => at.elapsed() > max_age,
107            None => true,
108        }
109    }
110
111    /// Get age of this snapshot.
112    pub fn age(&self) -> Option<Duration> {
113        self.captured_at.map(|at| at.elapsed())
114    }
115}
116
117/// State for animating change indicators.
118#[derive(Debug, Clone)]
119pub struct ChangeIndicatorState {
120    /// Animation phase (0.0 to 1.0, loops).
121    pub phase: f32,
122    /// Animation speed multiplier.
123    pub speed: f32,
124    /// Whether animation is enabled.
125    pub enabled: bool,
126}
127
128impl Default for ChangeIndicatorState {
129    fn default() -> Self {
130        Self {
131            phase: 0.0,
132            speed: 1.0,
133            enabled: true,
134        }
135    }
136}
137
138impl ChangeIndicatorState {
139    /// Advance the animation by delta time.
140    pub fn tick(&mut self, dt: f32) {
141        if self.enabled {
142            self.phase = (self.phase + dt * self.speed) % 1.0;
143        }
144    }
145
146    /// Get the current pulse scale (1.0 to 1.3).
147    pub fn pulse_scale(&self) -> f32 {
148        // Smooth sine-based pulse
149        let t = self.phase * std::f32::consts::TAU;
150        1.0 + 0.15 * (t.sin() * 0.5 + 0.5)
151    }
152
153    /// Get the current alpha for outer ring (fades in/out).
154    pub fn ring_alpha(&self) -> f32 {
155        let t = self.phase * std::f32::consts::TAU;
156        0.3 + 0.4 * (t.sin() * 0.5 + 0.5)
157    }
158}
159
160/// Identifier for nodes within the `SourceCodeGraph`.
161#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162pub struct NodeId(pub u64);
163
164/// Identifier for edges within the `SourceCodeGraph`.
165#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
166pub struct EdgeId(pub u64);
167
168/// Enumerates the kinds of nodes that can populate the `SourceCodeGraph`.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
170pub enum GraphNodeKind {
171    /// A logical module, typically aligned to a crate or package.
172    Module,
173    /// A discrete file in the repository.
174    File,
175    /// Directory or folder that contains additional nodes.
176    Directory,
177    /// A long-running service entry point.
178    Service,
179    /// Automated test suites or harnesses.
180    Test,
181    /// Any other kind that does not fit the curated list.
182    #[default]
183    Other,
184}
185
186/// Captures metadata for a node in the graph.
187#[derive(Debug, Default, Clone, Serialize, Deserialize)]
188pub struct GraphNode {
189    /// Unique identifier for this node.
190    pub id: NodeId,
191    /// Human readable name.
192    pub name: String,
193    /// Category associated with the node.
194    pub kind: GraphNodeKind,
195    /// Arbitrary metadata, e.g. language, path, ownership.
196    pub metadata: HashMap<String, String>,
197}
198
199/// Represents connections between graph nodes.
200#[derive(Debug, Default, Clone, Serialize, Deserialize)]
201pub struct GraphEdge {
202    /// Unique identifier for this edge.
203    pub id: EdgeId,
204    /// Originating node identifier.
205    pub from: NodeId,
206    /// Destination node identifier.
207    pub to: NodeId,
208    /// Description of the relationship ("imports", "calls", etc.).
209    pub relationship: String,
210    /// Arbitrary metadata for the relationship.
211    pub metadata: HashMap<String, String>,
212}
213
214/// Aggregate graph describing the full software project topology.
215#[derive(Debug, Default, Clone, Serialize, Deserialize)]
216pub struct SourceCodeGraph {
217    /// All nodes that make up the graph.
218    pub nodes: Vec<GraphNode>,
219    /// All edges that connect nodes in the graph.
220    pub edges: Vec<GraphEdge>,
221    /// Arbitrary metadata about the entire graph snapshot.
222    pub metadata: HashMap<String, String>,
223}
224
225impl SourceCodeGraph {
226    /// Creates an empty graph with no nodes or edges.
227    pub fn empty() -> Self {
228        Self::default()
229    }
230
231    /// Returns the number of nodes currently tracked.
232    pub fn node_count(&self) -> usize {
233        self.nodes.len()
234    }
235
236    /// Returns the number of edges currently tracked.
237    pub fn edge_count(&self) -> usize {
238        self.edges.len()
239    }
240
241    /// Convert to petgraph StableDiGraph for visualization/analysis.
242    /// Returns the graph and a mapping from NodeIndex to NodeId.
243    pub fn to_petgraph(&self) -> (StableDiGraph<GraphNode, String>, HashMap<NodeId, NodeIndex>) {
244        let mut graph = StableDiGraph::new();
245        let mut id_to_index = HashMap::new();
246
247        // Add all nodes
248        for node in &self.nodes {
249            let idx = graph.add_node(node.clone());
250            id_to_index.insert(node.id, idx);
251        }
252
253        // Add all edges
254        for edge in &self.edges {
255            if let (Some(&from_idx), Some(&to_idx)) =
256                (id_to_index.get(&edge.from), id_to_index.get(&edge.to))
257            {
258                graph.add_edge(from_idx, to_idx, edge.relationship.clone());
259            }
260        }
261
262        (graph, id_to_index)
263    }
264}
265
266/// Types of references detected between source files.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
268pub enum ReferenceKind {
269    /// Rust `use` statement
270    Uses,
271    /// Python/JS/TS `import` statement
272    Imports,
273    /// Trait or interface implementation
274    Implements,
275    /// Filesystem hierarchy (parent->child)
276    Contains,
277}
278
279impl std::fmt::Display for ReferenceKind {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        match self {
282            ReferenceKind::Uses => write!(f, "uses"),
283            ReferenceKind::Imports => write!(f, "imports"),
284            ReferenceKind::Implements => write!(f, "implements"),
285            ReferenceKind::Contains => write!(f, "contains"),
286        }
287    }
288}
289
290/// A detected reference from one source to another.
291#[derive(Debug, Clone)]
292pub struct SourceReference {
293    /// Path of the source file containing the reference
294    pub source_path: PathBuf,
295    /// Type of reference
296    pub kind: ReferenceKind,
297    /// Target path (may be partial, resolved later)
298    pub target_route: PathBuf,
299}
300
301/// Builder for constructing a `SourceCodeGraph` from project data.
302#[derive(Debug, Default)]
303pub struct SourceCodeGraphBuilder {
304    nodes: Vec<GraphNode>,
305    edges: Vec<GraphEdge>,
306    path_to_node: HashMap<PathBuf, NodeId>,
307    next_node_id: u64,
308    next_edge_id: u64,
309    metadata: HashMap<String, String>,
310}
311
312impl SourceCodeGraphBuilder {
313    /// Create a new builder.
314    pub fn new() -> Self {
315        Self::default()
316    }
317
318    /// Set metadata for the graph.
319    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
320        self.metadata.insert(key.into(), value.into());
321        self
322    }
323
324    /// Add a directory node.
325    pub fn add_directory(&mut self, path: &Path) -> NodeId {
326        if let Some(&id) = self.path_to_node.get(path) {
327            return id;
328        }
329
330        let id = NodeId(self.next_node_id);
331        self.next_node_id += 1;
332
333        let name = path
334            .file_name()
335            .and_then(|n| n.to_str())
336            .unwrap_or_else(|| path.to_str().unwrap_or("."))
337            .to_string();
338
339        let mut metadata = HashMap::new();
340        metadata.insert("path".to_string(), path.to_string_lossy().to_string());
341
342        self.nodes.push(GraphNode {
343            id,
344            name,
345            kind: GraphNodeKind::Directory,
346            metadata,
347        });
348
349        self.path_to_node.insert(path.to_path_buf(), id);
350        id
351    }
352
353    /// Add a file node with optional language detection.
354    pub fn add_file(&mut self, path: &Path, relative_path: &str) -> NodeId {
355        if let Some(&id) = self.path_to_node.get(path) {
356            return id;
357        }
358
359        let id = NodeId(self.next_node_id);
360        self.next_node_id += 1;
361
362        let name = path
363            .file_name()
364            .and_then(|n| n.to_str())
365            .unwrap_or_else(|| path.to_str().unwrap_or("unknown"))
366            .to_string();
367
368        // Determine kind based on extension
369        let kind = match path.extension().and_then(|e| e.to_str()) {
370            Some("rs") | Some("py") | Some("js") | Some("ts") | Some("tsx") | Some("jsx")
371            | Some("go") | Some("java") | Some("c") | Some("cpp") | Some("h") | Some("hpp") => {
372                if relative_path.contains("test") || name.starts_with("test_") {
373                    GraphNodeKind::Test
374                } else if name == "mod.rs"
375                    || name == "__init__.py"
376                    || name == "index.ts"
377                    || name == "index.js"
378                {
379                    GraphNodeKind::Module
380                } else {
381                    GraphNodeKind::File
382                }
383            }
384            _ => GraphNodeKind::File,
385        };
386
387        let mut metadata = HashMap::new();
388        metadata.insert("path".to_string(), path.to_string_lossy().to_string());
389        metadata.insert("relative_path".to_string(), relative_path.to_string());
390
391        if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
392            metadata.insert("extension".to_string(), ext.to_string());
393            metadata.insert(
394                "language".to_string(),
395                extension_to_language(ext).to_string(),
396            );
397        }
398
399        self.nodes.push(GraphNode {
400            id,
401            name,
402            kind,
403            metadata,
404        });
405
406        self.path_to_node.insert(path.to_path_buf(), id);
407        id
408    }
409
410    /// Add a hierarchy edge (parent contains child).
411    pub fn add_hierarchy_edge(&mut self, parent_path: &Path, child_path: &Path) {
412        if let (Some(&parent_id), Some(&child_id)) = (
413            self.path_to_node.get(parent_path),
414            self.path_to_node.get(child_path),
415        ) {
416            if parent_id != child_id {
417                self.add_edge(parent_id, child_id, ReferenceKind::Contains);
418            }
419        }
420    }
421
422    /// Add an edge between two nodes.
423    pub fn add_edge(&mut self, from: NodeId, to: NodeId, kind: ReferenceKind) {
424        let id = EdgeId(self.next_edge_id);
425        self.next_edge_id += 1;
426
427        self.edges.push(GraphEdge {
428            id,
429            from,
430            to,
431            relationship: kind.to_string(),
432            metadata: HashMap::new(),
433        });
434    }
435
436    /// Get NodeId for a path if it exists.
437    pub fn get_node_id(&self, path: &Path) -> Option<NodeId> {
438        self.path_to_node.get(path).copied()
439    }
440
441    /// Find a node by matching path suffix (for reference resolution).
442    pub fn find_node_by_path_suffix(&self, route: &Path) -> Option<NodeId> {
443        let route_str = route.to_string_lossy();
444
445        for (path, &node_id) in &self.path_to_node {
446            let path_str = path.to_string_lossy();
447
448            // Strategy 1: Direct suffix match
449            if path_str.ends_with(route_str.as_ref()) {
450                return Some(node_id);
451            }
452
453            // Strategy 2: Normalized comparison
454            let normalized_path: String = path_str.trim_start_matches("./").replace('\\', "/");
455            let normalized_route: String = route_str.trim_start_matches("./").replace('\\', "/");
456            if normalized_path.ends_with(&normalized_route) {
457                return Some(node_id);
458            }
459
460            // Strategy 3: Module path matching (e.g., core/models.rs -> src/core/models.rs)
461            let route_parts: Vec<&str> = normalized_route.split('/').collect();
462            let path_parts: Vec<&str> = normalized_path.split('/').collect();
463            if route_parts.len() <= path_parts.len() {
464                for window in path_parts.windows(route_parts.len()) {
465                    if window == route_parts.as_slice() {
466                        return Some(node_id);
467                    }
468                }
469            }
470
471            // Strategy 4: Filename match
472            if let (Some(file_name), Some(route_name)) = (
473                path.file_name().and_then(|n| n.to_str()),
474                route.file_name().and_then(|n| n.to_str()),
475            ) {
476                if file_name == route_name {
477                    return Some(node_id);
478                }
479            }
480        }
481
482        None
483    }
484
485    /// Get the current node count.
486    pub fn node_count(&self) -> usize {
487        self.nodes.len()
488    }
489
490    /// Get the current edge count.
491    pub fn edge_count(&self) -> usize {
492        self.edges.len()
493    }
494
495    /// Build the final `SourceCodeGraph`.
496    pub fn build(self) -> SourceCodeGraph {
497        SourceCodeGraph {
498            nodes: self.nodes,
499            edges: self.edges,
500            metadata: self.metadata,
501        }
502    }
503}
504
505/// Detect references in Rust source code.
506pub fn detect_rust_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
507    let mut refs = Vec::new();
508
509    for line in content.lines() {
510        let trimmed = line.trim();
511
512        if !trimmed.starts_with("use ") {
513            continue;
514        }
515
516        // Extract module path from use statement
517        let use_part = trimmed
518            .strip_prefix("use ")
519            .unwrap_or("")
520            .split(';')
521            .next()
522            .unwrap_or("")
523            .split('{')
524            .next()
525            .unwrap_or("")
526            .trim();
527
528        if use_part.is_empty() {
529            continue;
530        }
531
532        // Only track local imports
533        let is_local = use_part.starts_with("crate::")
534            || use_part.starts_with("self::")
535            || use_part.starts_with("super::");
536
537        if !is_local {
538            continue;
539        }
540
541        let module_path = use_part
542            .strip_prefix("crate::")
543            .or_else(|| use_part.strip_prefix("self::"))
544            .or_else(|| use_part.strip_prefix("super::"))
545            .unwrap_or(use_part);
546
547        // Convert module path to file path
548        let path_str = module_path
549            .replace("::", "/")
550            .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_')
551            .to_string();
552
553        refs.push(SourceReference {
554            source_path: source_path.to_path_buf(),
555            kind: ReferenceKind::Uses,
556            target_route: PathBuf::from(format!("{}.rs", path_str)),
557        });
558    }
559
560    refs
561}
562
563/// Detect references in Python source code.
564pub fn detect_python_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
565    let mut refs = Vec::new();
566
567    for line in content.lines() {
568        let trimmed = line.trim();
569
570        // Match "import module" statements
571        if trimmed.starts_with("import ") && !trimmed.starts_with("import(") {
572            let import_part = trimmed
573                .strip_prefix("import ")
574                .unwrap_or("")
575                .split_whitespace()
576                .next()
577                .unwrap_or("")
578                .split(',')
579                .next()
580                .unwrap_or("")
581                .trim();
582
583            if !import_part.is_empty() {
584                let path_str = import_part.replace('.', "/");
585                refs.push(SourceReference {
586                    source_path: source_path.to_path_buf(),
587                    kind: ReferenceKind::Imports,
588                    target_route: PathBuf::from(format!("{}.py", path_str)),
589                });
590            }
591        }
592
593        // Match "from module import something" statements
594        if let Some(module_part) = trimmed
595            .strip_prefix("from ")
596            .and_then(|s| s.split(" import ").next())
597        {
598            let module = module_part.trim();
599            if !module.is_empty() && module != "." && !module.starts_with("..") {
600                let path_str = module.replace('.', "/");
601                refs.push(SourceReference {
602                    source_path: source_path.to_path_buf(),
603                    kind: ReferenceKind::Imports,
604                    target_route: PathBuf::from(format!("{}.py", path_str)),
605                });
606            }
607        }
608    }
609
610    refs
611}
612
613/// Detect references in TypeScript/JavaScript source code.
614pub fn detect_ts_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
615    let mut refs = Vec::new();
616
617    for line in content.lines() {
618        let trimmed = line.trim();
619
620        // Match import statements: import X from 'path' or import 'path'
621        if trimmed.starts_with("import ") {
622            // Extract path from quotes
623            if let Some(path_start) = trimmed.find(['\'', '"']) {
624                let quote_char = trimmed.chars().nth(path_start).unwrap();
625                let rest = &trimmed[path_start + 1..];
626                if let Some(path_end) = rest.find(quote_char) {
627                    let import_path = &rest[..path_end];
628                    // Only track relative imports
629                    if import_path.starts_with('.') {
630                        refs.push(SourceReference {
631                            source_path: source_path.to_path_buf(),
632                            kind: ReferenceKind::Imports,
633                            target_route: PathBuf::from(import_path),
634                        });
635                    }
636                }
637            }
638        }
639    }
640
641    refs
642}
643
644/// Detect references based on file extension.
645pub fn detect_references(content: &str, source_path: &Path) -> Vec<SourceReference> {
646    match source_path.extension().and_then(|e| e.to_str()) {
647        Some("rs") => detect_rust_references(content, source_path),
648        Some("py") => detect_python_references(content, source_path),
649        Some("ts") | Some("tsx") | Some("js") | Some("jsx") => {
650            detect_ts_references(content, source_path)
651        }
652        _ => Vec::new(),
653    }
654}
655
656/// Map file extension to language name.
657fn extension_to_language(ext: &str) -> &'static str {
658    match ext {
659        "rs" => "rust",
660        "py" => "python",
661        "js" => "javascript",
662        "ts" => "typescript",
663        "tsx" => "typescript",
664        "jsx" => "javascript",
665        "go" => "go",
666        "java" => "java",
667        "c" | "h" => "c",
668        "cpp" | "hpp" | "cc" | "cxx" => "cpp",
669        "md" => "markdown",
670        "json" => "json",
671        "yaml" | "yml" => "yaml",
672        "toml" => "toml",
673        _ => "unknown",
674    }
675}
676
677/// Represents an explicitly declared vibe (intent/spec/decision) attached to graph regions.
678#[derive(Debug, Clone, Serialize, Deserialize)]
679pub struct Vibe {
680    /// Stable identifier for referencing the vibe.
681    pub id: String,
682    /// Short title summarizing the intent.
683    pub title: String,
684    /// Richer description with context.
685    pub description: String,
686    /// Target nodes within the graph impacted by the vibe.
687    pub targets: Vec<NodeId>,
688    /// Actor (human or machine) responsible for the vibe.
689    pub created_by: String,
690    /// Timestamp when the vibe entered the system.
691    pub created_at: SystemTime,
692    /// Optional tags or attributes.
693    pub metadata: HashMap<String, String>,
694}
695
696/// Canonical definition of governing rules in effect for a graph.
697#[derive(Debug, Default, Clone, Serialize, Deserialize)]
698pub struct Constitution {
699    /// Unique name of the constitution.
700    pub name: String,
701    /// Semantic version or revision identifier.
702    pub version: String,
703    /// Human-readable description of the guardrails.
704    pub description: String,
705    /// Simple list of policies; future versions may embed richer data.
706    pub policies: Vec<String>,
707}
708
709/// Generic payload that cells in the automaton can store.
710pub type StatePayload = Value;
711
712/// Captures the state of an individual cell in the LLM cellular automaton.
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct CellState {
715    /// Node the cell is associated with.
716    pub node_id: NodeId,
717    /// Arbitrary structured state payload.
718    pub payload: StatePayload,
719    /// Tracking field for energy, confidence, or strength indicators.
720    pub activation: f32,
721    /// Timestamp for the most recent update.
722    pub last_updated: SystemTime,
723    /// Free-form annotations (signals, metrics, citations, etc.).
724    pub annotations: HashMap<String, String>,
725}
726
727impl CellState {
728    /// Creates a fresh `CellState` wrapping the provided payload.
729    pub fn new(node_id: NodeId, payload: StatePayload) -> Self {
730        Self {
731            node_id,
732            payload,
733            activation: 0.0,
734            last_updated: SystemTime::now(),
735            annotations: HashMap::new(),
736        }
737    }
738}
739
740/// Represents a snapshot of the entire system ready to be fossilized in Git.
741#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct Snapshot {
743    /// Identifier suitable for referencing the snapshot in storage.
744    pub id: String,
745    /// Captured graph at the time of snapshot.
746    pub graph: SourceCodeGraph,
747    /// All vibes considered part of the snapshot.
748    pub vibes: Vec<Vibe>,
749    /// Cell states for the automaton corresponding to the snapshot.
750    pub cell_states: Vec<CellState>,
751    /// Constitution in effect when the snapshot was created.
752    pub constitution: Constitution,
753    /// Timestamp for when the snapshot was recorded.
754    pub created_at: SystemTime,
755}