1use 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#[cfg(not(target_arch = "wasm32"))]
12use std::time::Instant;
13#[cfg(target_arch = "wasm32")]
14use web_time::Instant;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum GitChangeKind {
23 Modified,
25 Added,
27 Deleted,
29 RenamedFrom,
31 RenamedTo,
33 Untracked,
35}
36
37impl GitChangeKind {
38 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct GitFileChange {
66 pub path: PathBuf,
68 pub kind: GitChangeKind,
70 pub staged: bool,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76pub struct GitChangeSnapshot {
77 pub changes: Vec<GitFileChange>,
79 #[serde(skip)]
81 pub captured_at: Option<Instant>,
82}
83
84impl GitChangeSnapshot {
85 pub fn new() -> Self {
87 Self {
88 changes: Vec::new(),
89 captured_at: Some(Instant::now()),
90 }
91 }
92
93 pub fn has_changes(&self, path: &Path) -> bool {
95 self.changes.iter().any(|c| c.path == path)
96 }
97
98 pub fn get_change(&self, path: &Path) -> Option<&GitFileChange> {
100 self.changes.iter().find(|c| c.path == path)
101 }
102
103 pub fn changed_paths(&self) -> impl Iterator<Item = &Path> {
105 self.changes.iter().map(|c| c.path.as_path())
106 }
107
108 pub fn count_by_kind(&self, kind: GitChangeKind) -> usize {
110 self.changes.iter().filter(|c| c.kind == kind).count()
111 }
112
113 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 pub fn age(&self) -> Option<Duration> {
123 self.captured_at.map(|at| at.elapsed())
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct ChangeIndicatorState {
130 pub phase: f32,
132 pub speed: f32,
134 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 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 pub fn pulse_scale(&self) -> f32 {
158 let t = self.phase * std::f32::consts::TAU;
160 1.0 + 0.15 * (t.sin() * 0.5 + 0.5)
161 }
162
163 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
172pub struct NodeId(pub u64);
173
174#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176pub struct EdgeId(pub u64);
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
180pub enum GraphNodeKind {
181 Module,
183 File,
185 Directory,
187 Service,
189 Test,
191 #[default]
193 Other,
194}
195
196#[derive(Debug, Default, Clone, Serialize, Deserialize)]
198pub struct GraphNode {
199 pub id: NodeId,
201 pub name: String,
203 pub kind: GraphNodeKind,
205 pub metadata: HashMap<String, String>,
207}
208
209#[derive(Debug, Default, Clone, Serialize, Deserialize)]
211pub struct GraphEdge {
212 pub id: EdgeId,
214 pub from: NodeId,
216 pub to: NodeId,
218 pub relationship: String,
220 pub metadata: HashMap<String, String>,
222}
223
224#[derive(Debug, Default, Clone, Serialize, Deserialize)]
226pub struct SourceCodeGraph {
227 pub nodes: Vec<GraphNode>,
229 pub edges: Vec<GraphEdge>,
231 pub metadata: HashMap<String, String>,
233}
234
235impl SourceCodeGraph {
236 pub fn empty() -> Self {
238 Self::default()
239 }
240
241 pub fn node_count(&self) -> usize {
243 self.nodes.len()
244 }
245
246 pub fn edge_count(&self) -> usize {
248 self.edges.len()
249 }
250
251 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 for node in &self.nodes {
259 let idx = graph.add_node(node.clone());
260 id_to_index.insert(node.id, idx);
261 }
262
263 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278pub enum ReferenceKind {
279 Uses,
281 Imports,
283 Implements,
285 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#[derive(Debug, Clone)]
302pub struct SourceReference {
303 pub source_path: PathBuf,
305 pub kind: ReferenceKind,
307 pub target_route: PathBuf,
309}
310
311#[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 pub fn new() -> Self {
325 Self::default()
326 }
327
328 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 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 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 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 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 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 pub fn get_node_id(&self, path: &Path) -> Option<NodeId> {
448 self.path_to_node.get(path).copied()
449 }
450
451 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 if path_str.ends_with(route_str.as_ref()) {
460 return Some(node_id);
461 }
462
463 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 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 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 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 pub fn node_count(&self) -> usize {
509 self.nodes.len()
510 }
511
512 pub fn edge_count(&self) -> usize {
514 self.edges.len()
515 }
516
517 pub fn build(self) -> SourceCodeGraph {
519 SourceCodeGraph {
520 nodes: self.nodes,
521 edges: self.edges,
522 metadata: self.metadata,
523 }
524 }
525}
526
527pub 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 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 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 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 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
603pub 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 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 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
653pub 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 if trimmed.starts_with("import ") {
662 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 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
684pub 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 if trimmed.is_empty() || trimmed.starts_with("--") || trimmed.starts_with("/-") {
696 continue;
697 }
698
699 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 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
741pub 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
754fn 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#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct Vibe {
779 pub id: String,
781 pub title: String,
783 pub description: String,
785 pub targets: Vec<NodeId>,
787 pub created_by: String,
789 pub created_at: SystemTime,
791 pub metadata: HashMap<String, String>,
793}
794
795#[derive(Debug, Default, Clone, Serialize, Deserialize)]
797pub struct Constitution {
798 pub name: String,
800 pub version: String,
802 pub description: String,
804 pub policies: Vec<String>,
806}
807
808pub type StatePayload = Value;
810
811#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct CellState {
814 pub node_id: NodeId,
816 pub payload: StatePayload,
818 pub activation: f32,
820 pub last_updated: SystemTime,
822 pub annotations: HashMap<String, String>,
824}
825
826impl CellState {
827 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#[derive(Debug, Clone, Serialize, Deserialize)]
841pub struct Snapshot {
842 pub id: String,
844 pub graph: SourceCodeGraph,
846 pub vibes: Vec<Vibe>,
848 pub cell_states: Vec<CellState>,
850 pub constitution: Constitution,
852 pub created_at: SystemTime,
854}
855
856#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
858pub enum LayoutStrategy {
859 #[default]
861 Flat,
862 Lattice { width: usize, group_by_row: bool },
864 Direct,
866 Preserve,
868 Modular,
870}
871
872#[derive(Debug, Clone)]
888pub struct SampleContext<'a> {
889 pub node: &'a GraphNode,
891 pub neighbors: Vec<NeighborRef<'a>>,
893 pub content: Option<&'a str>,
895 pub annotations: &'a HashMap<String, Value>,
899 pub graph_metadata: &'a HashMap<String, String>,
901}
902
903#[derive(Debug, Clone)]
905pub struct NeighborRef<'a> {
906 pub node: &'a GraphNode,
908 pub edge: &'a GraphEdge,
910}
911
912#[derive(Debug, Clone, Serialize, Deserialize)]
914pub struct SampleArtifact {
915 pub node_id: NodeId,
917 pub value: Value,
919}
920
921#[derive(Debug, Clone, Default, Serialize, Deserialize)]
923pub struct SampleResult {
924 pub sampler_id: String,
926 pub artifacts: Vec<SampleArtifact>,
928 pub metadata: HashMap<String, Value>,
930}
931
932impl SampleResult {
933 pub fn get(&self, node_id: NodeId) -> Option<&SampleArtifact> {
935 self.artifacts.iter().find(|a| a.node_id == node_id)
936 }
937
938 pub fn len(&self) -> usize {
940 self.artifacts.len()
941 }
942
943 pub fn is_empty(&self) -> bool {
945 self.artifacts.is_empty()
946 }
947
948 pub fn iter(&self) -> impl Iterator<Item = (NodeId, &Value)> {
950 self.artifacts.iter().map(|a| (a.node_id, &a.value))
951 }
952}
953
954#[derive(Default)]
956pub enum NodeSelector {
957 #[default]
959 All,
960 ByKind(GraphNodeKind),
962 Explicit(Vec<NodeId>),
964 HasMetadata(String),
966 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 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
995pub type AnnotationMap = HashMap<NodeId, HashMap<String, Value>>;
997
998pub trait Sampler: Send + Sync {
1005 fn id(&self) -> &str;
1007
1008 fn selector(&self) -> NodeSelector {
1010 NodeSelector::All
1011 }
1012
1013 fn compute(&self, ctx: &SampleContext<'_>) -> Result<Option<Value>, SamplerError>;
1016
1017 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#[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
1086pub 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 pub fn with_stage(mut self, sampler: Box<dyn Sampler>) -> Self {
1099 self.stages.push(sampler);
1100 self
1101 }
1102
1103 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
1135impl SourceCodeGraph {
1138 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
1163pub 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
1177pub 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
1210pub struct MetadataSampler {
1214 keys: Vec<String>,
1215}
1216
1217impl MetadataSampler {
1218 pub fn new(keys: Vec<String>) -> Self {
1220 Self { keys }
1221 }
1222
1223 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#[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 #[test]
1339 fn test_neighbors_returns_both_directions() {
1340 let graph = test_graph();
1341 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))); assert!(peer_ids.contains(&NodeId(1))); }
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 #[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); }
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 #[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 #[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 assert_eq!(result.len(), 1);
1421 let artifact = result.get(NodeId(1)).unwrap();
1422 assert_eq!(artifact.value["in"], 1);
1424 assert_eq!(artifact.value["out"], 1);
1425 assert_eq!(artifact.value["total"], 2);
1426 }
1427
1428 #[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 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); }
1449
1450 #[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 #[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 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 #[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 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 #[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}