1use std::collections::{BTreeMap, HashMap};
2use std::fmt;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use gobby_core::degradation::ServiceState;
6use gobby_core::falkor::{GraphClient, Row};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::config::Context;
11use crate::graph::typed_query;
12use crate::models::{ProjectionMetadata, ProjectionProvenance};
13
14const RELATES_TO_CODE: &str = "RELATES_TO_CODE";
15const DEFAULT_TOP_LIMIT: usize = 10;
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct BridgeEdgeHypothesis {
19 pub source_id: String,
20 pub target_symbol_id: String,
21 pub relation: String,
22 pub label: String,
23 pub read_only: bool,
24 pub metadata: ProjectionMetadata,
25}
26
27impl BridgeEdgeHypothesis {
28 pub fn new(
29 source_id: impl Into<String>,
30 target_symbol_id: impl Into<String>,
31 relation: impl Into<String>,
32 metadata: ProjectionMetadata,
33 ) -> Self {
34 Self {
35 source_id: source_id.into(),
36 target_symbol_id: target_symbol_id.into(),
37 relation: relation.into(),
38 label: "inferred hypothesis".to_string(),
39 read_only: true,
40 metadata: inferred_bridge_metadata(metadata),
41 }
42 }
43
44 pub fn inferred(
45 source_id: impl Into<String>,
46 target_symbol_id: impl Into<String>,
47 relation: impl Into<String>,
48 source_system: impl Into<String>,
49 confidence: Option<f64>,
50 ) -> Self {
51 Self::new(
52 source_id,
53 target_symbol_id,
54 relation,
55 ProjectionMetadata::inferred(source_system, confidence),
56 )
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ProjectGraphReport {
62 pub project_id: String,
63 pub generated_at: String,
64 pub summary: GraphReportSummary,
65 pub hotspots: GraphReportHotspots,
66 pub unresolved_targets: Vec<TargetFrequency>,
67 pub external_targets: Vec<TargetFrequency>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub bridge_summary: Option<BridgeReportSummary>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
71 pub bridge_edges: Vec<BridgeEdgeHypothesis>,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub degradation_details: Vec<ReportDegradation>,
74 pub suggested_investigation_questions: Vec<String>,
75 pub markdown: String,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct ProjectGraphReportOptions {
80 pub top_n: usize,
81}
82
83impl Default for ProjectGraphReportOptions {
84 fn default() -> Self {
85 Self {
86 top_n: DEFAULT_TOP_LIMIT,
87 }
88 }
89}
90
91impl ProjectGraphReportOptions {
92 fn normalized(self) -> Self {
93 Self {
94 top_n: self.top_n.max(1),
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100pub struct GraphReportSummary {
101 pub node_count: usize,
102 pub edge_count: usize,
103 pub node_counts_by_type: BTreeMap<String, usize>,
104 pub code_edge_counts: BTreeMap<String, usize>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GraphReportHotspots {
109 pub high_degree_files: Vec<GraphHotspot>,
110 pub high_degree_symbols: Vec<GraphHotspot>,
111 pub high_degree_modules: Vec<GraphHotspot>,
112 pub incoming_call_hotspots: Vec<GraphHotspot>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116pub struct GraphHotspot {
117 pub id: String,
118 pub name: String,
119 #[serde(rename = "type")]
120 pub node_type: String,
121 pub degree: usize,
122 pub incoming: usize,
123 pub outgoing: usize,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub file_path: Option<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct TargetFrequency {
130 pub id: String,
131 pub name: String,
132 pub count: usize,
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct BridgeReportSummary {
137 pub relation: String,
138 pub edge_count: usize,
139 pub inferred: bool,
140 pub read_only: bool,
141 pub source_system_counts: Vec<NamedCount>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub confidence_range: Option<ConfidenceRange>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct NamedCount {
148 pub name: String,
149 pub count: usize,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct ConfidenceRange {
154 pub min: f64,
155 pub max: f64,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
159pub struct ReportDegradation {
160 pub input: String,
161 pub required: bool,
162 pub detail: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ProjectGraphReportError {
167 GraphServiceNotConfigured,
168 GraphServiceUnreachable { message: String },
169 GraphQueryFailed { message: String },
170}
171
172impl fmt::Display for ProjectGraphReportError {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 Self::GraphServiceNotConfigured => {
176 f.write_str("FalkorDB is not configured; project graph report requires FalkorDB")
177 }
178 Self::GraphServiceUnreachable { message } => write!(
179 f,
180 "FalkorDB is unreachable; project graph report requires FalkorDB: {message}"
181 ),
182 Self::GraphQueryFailed { message } => {
183 write!(f, "project graph report query failed: {message}")
184 }
185 }
186 }
187}
188
189impl std::error::Error for ProjectGraphReportError {}
190
191#[derive(Debug, Clone, Default, PartialEq)]
192struct ReportGraphSnapshot {
193 nodes: Vec<ReportNode>,
194 code_edges: Vec<ReportCodeEdge>,
195 bridge_edges: BridgeEdgeInput,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199struct ReportNode {
200 id: String,
201 name: String,
202 node_type: String,
203 file_path: Option<String>,
204}
205
206impl ReportNode {
207 fn new(id: impl Into<String>, name: impl Into<String>, node_type: impl Into<String>) -> Self {
208 Self {
209 id: id.into(),
210 name: name.into(),
211 node_type: node_type.into(),
212 file_path: None,
213 }
214 }
215
216 #[cfg(test)]
217 fn with_file_path(mut self, file_path: impl Into<String>) -> Self {
218 self.file_path = Some(file_path.into());
219 self
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224struct ReportCodeEdge {
225 source: String,
226 target: String,
227 edge_type: String,
228}
229
230impl ReportCodeEdge {
231 fn new(
232 source: impl Into<String>,
233 target: impl Into<String>,
234 edge_type: impl Into<String>,
235 ) -> Self {
236 Self {
237 source: source.into(),
238 target: target.into(),
239 edge_type: edge_type.into(),
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq)]
245enum BridgeEdgeInput {
246 Available(Vec<BridgeEdgeHypothesis>),
247 Unavailable(String),
248}
249
250impl BridgeEdgeInput {
251 fn available(edges: Vec<BridgeEdgeHypothesis>) -> Self {
252 Self::Available(edges)
253 }
254
255 fn unavailable(reason: impl Into<String>) -> Self {
256 Self::Unavailable(reason.into())
257 }
258}
259
260impl Default for BridgeEdgeInput {
261 fn default() -> Self {
262 Self::Available(vec![])
263 }
264}
265
266#[derive(Debug, Clone, Copy, Default)]
267struct DegreeStats {
268 incoming: usize,
269 outgoing: usize,
270}
271
272pub fn generate_report(ctx: &Context) -> Result<ProjectGraphReport, ProjectGraphReportError> {
273 generate_report_with_options(ctx, ProjectGraphReportOptions::default())
274}
275
276pub fn generate_report_with_options(
277 ctx: &Context,
278 options: ProjectGraphReportOptions,
279) -> Result<ProjectGraphReport, ProjectGraphReportError> {
280 let Some(config) = ctx.falkordb.as_ref() else {
281 return Err(ProjectGraphReportError::GraphServiceNotConfigured);
282 };
283
284 let connection_config = config.connection_config();
285 let result = gobby_core::falkor::with_graph(
286 Some(&connection_config),
287 &config.graph_name,
288 ReportGraphSnapshot::default(),
289 |client| load_report_snapshot(client, &ctx.project_id),
290 );
291
292 match result {
293 Ok((snapshot, ServiceState::Available)) => Ok(generate_report_from_snapshot_with_options(
294 &ctx.project_id,
295 now_iso8601(),
296 snapshot,
297 options,
298 )),
299 Ok((_, ServiceState::NotConfigured)) => {
300 Err(ProjectGraphReportError::GraphServiceNotConfigured)
301 }
302 Ok((_, ServiceState::Unreachable { message })) => {
303 Err(ProjectGraphReportError::GraphServiceUnreachable { message })
304 }
305 Err(error) => Err(ProjectGraphReportError::GraphQueryFailed {
306 message: error.to_string(),
307 }),
308 }
309}
310
311pub fn empty_report(project_id: impl Into<String>) -> ProjectGraphReport {
312 generate_report_from_snapshot(project_id, now_iso8601(), ReportGraphSnapshot::default())
313}
314
315fn generate_report_from_snapshot(
316 project_id: impl Into<String>,
317 generated_at: impl Into<String>,
318 snapshot: ReportGraphSnapshot,
319) -> ProjectGraphReport {
320 generate_report_from_snapshot_with_options(
321 project_id,
322 generated_at,
323 snapshot,
324 ProjectGraphReportOptions::default(),
325 )
326}
327
328fn generate_report_from_snapshot_with_options(
329 project_id: impl Into<String>,
330 generated_at: impl Into<String>,
331 snapshot: ReportGraphSnapshot,
332 options: ProjectGraphReportOptions,
333) -> ProjectGraphReport {
334 let options = options.normalized();
335 let project_id = project_id.into();
336 let generated_at = generated_at.into();
337 let node_by_id = snapshot
338 .nodes
339 .iter()
340 .map(|node| (node.id.as_str(), node))
341 .collect::<HashMap<_, _>>();
342
343 let summary = summarize_graph(&snapshot.nodes, &snapshot.code_edges);
344 let hotspots = summarize_hotspots(&snapshot.nodes, &snapshot.code_edges, options.top_n);
345 let unresolved_targets = target_frequencies(
346 &snapshot.code_edges,
347 &node_by_id,
348 "unresolved",
349 options.top_n,
350 );
351 let external_targets =
352 target_frequencies(&snapshot.code_edges, &node_by_id, "external", options.top_n);
353
354 let (bridge_edges, mut degradation_details) = match snapshot.bridge_edges {
355 BridgeEdgeInput::Available(edges) => (normalize_bridge_edges(edges), vec![]),
356 BridgeEdgeInput::Unavailable(reason) => (
357 vec![],
358 vec![ReportDegradation {
359 input: RELATES_TO_CODE.to_string(),
360 required: false,
361 detail: reason,
362 }],
363 ),
364 };
365 let bridge_summary = summarize_bridge_edges(&bridge_edges);
366 let suggested_investigation_questions = suggested_questions(
367 &hotspots,
368 &unresolved_targets,
369 &external_targets,
370 bridge_summary.as_ref(),
371 °radation_details,
372 );
373 let markdown = render_markdown(RenderMarkdownInput {
374 project_id: &project_id,
375 generated_at: &generated_at,
376 summary: &summary,
377 hotspots: &hotspots,
378 unresolved_targets: &unresolved_targets,
379 external_targets: &external_targets,
380 bridge_summary: bridge_summary.as_ref(),
381 degradation_details: °radation_details,
382 top_n: options.top_n,
383 });
384
385 degradation_details.sort_by(|left, right| left.input.cmp(&right.input));
386
387 ProjectGraphReport {
388 project_id,
389 generated_at,
390 summary,
391 hotspots,
392 unresolved_targets,
393 external_targets,
394 bridge_summary,
395 bridge_edges,
396 degradation_details,
397 suggested_investigation_questions,
398 markdown,
399 }
400}
401
402fn load_report_snapshot(
403 client: &mut GraphClient,
404 project_id: &str,
405) -> anyhow::Result<ReportGraphSnapshot> {
406 let (query, params) = report_nodes_query(project_id);
407 let nodes = client
408 .query(&query, Some(params))?
409 .iter()
410 .filter_map(row_to_report_node)
411 .collect::<Vec<_>>();
412
413 let (query, params) = report_code_edges_query(project_id);
414 let code_edges = client
415 .query(&query, Some(params))?
416 .iter()
417 .filter_map(row_to_report_code_edge)
418 .collect::<Vec<_>>();
419
420 let (query, params) = report_bridge_edges_query(project_id);
421 let bridge_edges = match client.query(&query, Some(params)) {
422 Ok(rows) => BridgeEdgeInput::available(
423 rows.iter()
424 .filter_map(row_to_bridge_edge_hypothesis)
425 .collect(),
426 ),
427 Err(error) => BridgeEdgeInput::unavailable(format!("bridge edge query failed: {error}")),
428 };
429
430 Ok(ReportGraphSnapshot {
431 nodes,
432 code_edges,
433 bridge_edges,
434 })
435}
436
437fn report_nodes_query(project_id: &str) -> (String, HashMap<String, String>) {
438 (
439 "MATCH (n {project: $project}) \
440 WHERE n:CodeFile OR n:CodeSymbol OR n:CodeModule OR n:UnresolvedCallee OR n:ExternalSymbol \
441 RETURN coalesce(n.id, n.path, n.name) AS id, \
442 coalesce(n.name, n.path, n.id) AS name, \
443 CASE \
444 WHEN n:CodeFile THEN 'file' \
445 WHEN n:CodeModule THEN 'module' \
446 WHEN n:CodeSymbol THEN coalesce(n.kind, 'symbol') \
447 WHEN n:UnresolvedCallee THEN 'unresolved' \
448 WHEN n:ExternalSymbol THEN 'external' \
449 ELSE 'node' \
450 END AS node_type, \
451 coalesce(n.file_path, n.path) AS file_path"
452 .to_string(),
453 typed_query::string_params(&[("project", project_id)]),
454 )
455}
456
457fn report_code_edges_query(project_id: &str) -> (String, HashMap<String, String>) {
458 (
459 "MATCH (source {project: $project})-[r]->(target {project: $project}) \
460 WHERE type(r) IN ['DEFINES', 'IMPORTS', 'CALLS'] \
461 RETURN coalesce(source.id, source.path, source.name) AS source, \
462 coalesce(target.id, target.path, target.name) AS target, \
463 type(r) AS edge_type"
464 .to_string(),
465 typed_query::string_params(&[("project", project_id)]),
466 )
467}
468
469fn report_bridge_edges_query(project_id: &str) -> (String, HashMap<String, String>) {
470 (
471 "MATCH (source)-[r:RELATES_TO_CODE]->(target:CodeSymbol {project: $project}) \
472 RETURN coalesce(source.id, source.uuid, source.name) AS source_id, \
473 target.id AS target_symbol_id, \
474 'RELATES_TO_CODE' AS relation, \
475 r.provenance AS provenance, \
476 r.confidence AS confidence, \
477 coalesce(r.source_system, 'gobby-memory') AS source_system, \
478 r.source_file_path AS source_file_path, \
479 r.source_line AS source_line, \
480 r.source_symbol_id AS source_symbol_id, \
481 r.matching_method AS matching_method"
482 .to_string(),
483 typed_query::string_params(&[("project", project_id)]),
484 )
485}
486
487fn row_to_report_node(row: &Row) -> Option<ReportNode> {
488 let id = row_string(row, &["id"])?;
489 let name = row_string(row, &["name"]).unwrap_or_else(|| id.clone());
490 let node_type = row_string(row, &["node_type"]).unwrap_or_else(|| "node".to_string());
491 let mut node = ReportNode::new(id, name, node_type);
492 node.file_path = row_string(row, &["file_path"]);
493 Some(node)
494}
495
496fn row_to_report_code_edge(row: &Row) -> Option<ReportCodeEdge> {
497 let source = row_string(row, &["source"])?;
498 let target = row_string(row, &["target"])?;
499 let edge_type = row_string(row, &["edge_type"]).unwrap_or_else(|| "CALLS".to_string());
500 Some(ReportCodeEdge::new(source, target, edge_type))
501}
502
503fn row_to_bridge_edge_hypothesis(row: &Row) -> Option<BridgeEdgeHypothesis> {
504 let source_id = row_string(row, &["source_id"])?;
505 let target_symbol_id = row_string(row, &["target_symbol_id"])?;
506 let relation = row_string(row, &["relation"]).unwrap_or_else(|| RELATES_TO_CODE.to_string());
507 let source_system =
508 row_string(row, &["source_system"]).unwrap_or_else(|| "gobby-memory".to_string());
509
510 let mut metadata = ProjectionMetadata::new(
511 row_string(row, &["provenance"])
512 .and_then(|value| ProjectionProvenance::from_wire_value(&value))
513 .unwrap_or(ProjectionProvenance::Inferred),
514 source_system,
515 );
516 metadata.confidence = row_f64(row, &["confidence"]);
517 metadata.source_file_path = row_string(row, &["source_file_path"]);
518 metadata.source_line = row_usize(row, &["source_line"]);
519 metadata.source_symbol_id = row_string(row, &["source_symbol_id"]);
520 metadata.matching_method = row_string(row, &["matching_method"]);
521
522 Some(BridgeEdgeHypothesis::new(
523 source_id,
524 target_symbol_id,
525 relation,
526 metadata,
527 ))
528}
529
530fn summarize_graph(nodes: &[ReportNode], edges: &[ReportCodeEdge]) -> GraphReportSummary {
531 let mut node_counts_by_type = BTreeMap::new();
532 for node in nodes {
533 *node_counts_by_type
534 .entry(node.node_type.clone())
535 .or_insert(0) += 1;
536 }
537
538 let mut code_edge_counts = BTreeMap::new();
539 for edge in edges {
540 *code_edge_counts.entry(edge.edge_type.clone()).or_insert(0) += 1;
541 }
542
543 GraphReportSummary {
544 node_count: nodes.len(),
545 edge_count: edges.len(),
546 node_counts_by_type,
547 code_edge_counts,
548 }
549}
550
551fn summarize_hotspots(
552 nodes: &[ReportNode],
553 edges: &[ReportCodeEdge],
554 top_n: usize,
555) -> GraphReportHotspots {
556 let mut degree = HashMap::<&str, DegreeStats>::new();
557 let mut incoming_calls = HashMap::<&str, usize>::new();
558 for edge in edges {
559 degree.entry(&edge.source).or_default().outgoing += 1;
560 degree.entry(&edge.target).or_default().incoming += 1;
561 if edge.edge_type == "CALLS" {
562 *incoming_calls.entry(&edge.target).or_insert(0) += 1;
563 }
564 }
565
566 GraphReportHotspots {
567 high_degree_files: top_hotspots(nodes, °ree, top_n, |node| node.node_type == "file"),
568 high_degree_symbols: top_hotspots(nodes, °ree, top_n, |node| {
569 is_symbol_node(&node.node_type)
570 }),
571 high_degree_modules: top_hotspots(nodes, °ree, top_n, |node| node.node_type == "module"),
572 incoming_call_hotspots: top_incoming_call_hotspots(nodes, &incoming_calls, top_n),
573 }
574}
575
576fn top_hotspots(
577 nodes: &[ReportNode],
578 degree: &HashMap<&str, DegreeStats>,
579 top_n: usize,
580 include: impl Fn(&ReportNode) -> bool,
581) -> Vec<GraphHotspot> {
582 let mut hotspots = nodes
583 .iter()
584 .filter(|node| include(node))
585 .filter_map(|node| {
586 let stats = degree.get(node.id.as_str())?;
587 let total = stats.incoming + stats.outgoing;
588 (total > 0).then(|| GraphHotspot {
589 id: node.id.clone(),
590 name: node.name.clone(),
591 node_type: node.node_type.clone(),
592 degree: total,
593 incoming: stats.incoming,
594 outgoing: stats.outgoing,
595 file_path: node.file_path.clone(),
596 })
597 })
598 .collect::<Vec<_>>();
599 sort_hotspots(&mut hotspots);
600 hotspots.truncate(top_n);
601 hotspots
602}
603
604fn top_incoming_call_hotspots(
605 nodes: &[ReportNode],
606 incoming_calls: &HashMap<&str, usize>,
607 top_n: usize,
608) -> Vec<GraphHotspot> {
609 let mut hotspots = nodes
610 .iter()
611 .filter(|node| is_symbol_node(&node.node_type))
612 .filter_map(|node| {
613 let incoming = incoming_calls.get(node.id.as_str()).copied().unwrap_or(0);
614 (incoming > 0).then(|| GraphHotspot {
615 id: node.id.clone(),
616 name: node.name.clone(),
617 node_type: node.node_type.clone(),
618 degree: incoming,
619 incoming,
620 outgoing: 0,
621 file_path: node.file_path.clone(),
622 })
623 })
624 .collect::<Vec<_>>();
625 sort_hotspots(&mut hotspots);
626 hotspots.truncate(top_n);
627 hotspots
628}
629
630fn target_frequencies(
631 edges: &[ReportCodeEdge],
632 node_by_id: &HashMap<&str, &ReportNode>,
633 target_type: &str,
634 top_n: usize,
635) -> Vec<TargetFrequency> {
636 let mut counts = BTreeMap::<String, TargetFrequency>::new();
637 for edge in edges.iter().filter(|edge| edge.edge_type == "CALLS") {
638 let Some(node) = node_by_id.get(edge.target.as_str()) else {
639 continue;
640 };
641 if node.node_type != target_type {
642 continue;
643 }
644 let entry = counts
645 .entry(node.id.clone())
646 .or_insert_with(|| TargetFrequency {
647 id: node.id.clone(),
648 name: node.name.clone(),
649 count: 0,
650 });
651 entry.count += 1;
652 }
653
654 let mut frequencies = counts.into_values().collect::<Vec<_>>();
655 frequencies.sort_by(|left, right| {
656 right
657 .count
658 .cmp(&left.count)
659 .then_with(|| left.name.cmp(&right.name))
660 .then_with(|| left.id.cmp(&right.id))
661 });
662 frequencies.truncate(top_n);
663 frequencies
664}
665
666fn summarize_bridge_edges(edges: &[BridgeEdgeHypothesis]) -> Option<BridgeReportSummary> {
667 if edges.is_empty() {
668 return None;
669 }
670
671 let mut source_counts = BTreeMap::<String, usize>::new();
672 let mut confidence_min = f64::INFINITY;
673 let mut confidence_max = f64::NEG_INFINITY;
674 let mut has_confidence = false;
675 for edge in edges {
676 *source_counts
677 .entry(edge.metadata.source_system.clone())
678 .or_insert(0) += 1;
679 if let Some(confidence) = edge.metadata.confidence
680 && confidence.is_finite()
681 {
682 confidence_min = confidence_min.min(confidence);
683 confidence_max = confidence_max.max(confidence);
684 has_confidence = true;
685 }
686 }
687
688 let source_system_counts = source_counts
689 .into_iter()
690 .map(|(name, count)| NamedCount { name, count })
691 .collect();
692
693 Some(BridgeReportSummary {
694 relation: RELATES_TO_CODE.to_string(),
695 edge_count: edges.len(),
696 inferred: true,
697 read_only: true,
698 source_system_counts,
699 confidence_range: has_confidence.then_some(ConfidenceRange {
700 min: confidence_min,
701 max: confidence_max,
702 }),
703 })
704}
705
706fn normalize_bridge_edges(edges: Vec<BridgeEdgeHypothesis>) -> Vec<BridgeEdgeHypothesis> {
707 edges
708 .into_iter()
709 .map(|edge| {
710 BridgeEdgeHypothesis::new(
711 edge.source_id,
712 edge.target_symbol_id,
713 edge.relation,
714 edge.metadata,
715 )
716 })
717 .collect()
718}
719
720fn suggested_questions(
721 hotspots: &GraphReportHotspots,
722 unresolved_targets: &[TargetFrequency],
723 external_targets: &[TargetFrequency],
724 bridge_summary: Option<&BridgeReportSummary>,
725 degradation_details: &[ReportDegradation],
726) -> Vec<String> {
727 let mut questions =
728 vec!["Which high-degree files or symbols should be reviewed before refactors?".to_string()];
729
730 if !hotspots.incoming_call_hotspots.is_empty() {
731 questions.push("Which incoming-call hotspots define the largest blast radius?".to_string());
732 }
733 if !unresolved_targets.is_empty() || !external_targets.is_empty() {
734 questions.push(
735 "Which unresolved or external call targets should be resolved first?".to_string(),
736 );
737 }
738 if bridge_summary.is_some() {
739 questions
740 .push("Which inferred RELATES_TO_CODE bridges need human confirmation?".to_string());
741 }
742 if !degradation_details.is_empty() {
743 questions.push(
744 "Which degraded optional inputs should be restored for the next report?".to_string(),
745 );
746 }
747
748 questions
749}
750
751struct RenderMarkdownInput<'a> {
752 project_id: &'a str,
753 generated_at: &'a str,
754 summary: &'a GraphReportSummary,
755 hotspots: &'a GraphReportHotspots,
756 unresolved_targets: &'a [TargetFrequency],
757 external_targets: &'a [TargetFrequency],
758 bridge_summary: Option<&'a BridgeReportSummary>,
759 degradation_details: &'a [ReportDegradation],
760 top_n: usize,
761}
762
763fn render_markdown(input: RenderMarkdownInput<'_>) -> String {
764 let mut lines = vec![
765 "# Project Graph Report".to_string(),
766 String::new(),
767 format!("- Project: {}", input.project_id),
768 format!("- Generated: {}", input.generated_at),
769 format!("- Nodes: {}", input.summary.node_count),
770 format!("- Edges: {}", input.summary.edge_count),
771 ];
772
773 if !input.summary.code_edge_counts.is_empty() {
774 lines.push(format!(
775 "- Code edges: {}",
776 named_counts_inline(&input.summary.code_edge_counts)
777 ));
778 }
779
780 append_hotspot_section(
781 &mut lines,
782 "High-degree files",
783 &input.hotspots.high_degree_files,
784 input.top_n,
785 );
786 append_hotspot_section(
787 &mut lines,
788 "High-degree symbols",
789 &input.hotspots.high_degree_symbols,
790 input.top_n,
791 );
792 append_hotspot_section(
793 &mut lines,
794 "Incoming-call hotspots",
795 &input.hotspots.incoming_call_hotspots,
796 input.top_n,
797 );
798 append_target_section(
799 &mut lines,
800 "Unresolved call targets",
801 input.unresolved_targets,
802 input.top_n,
803 );
804 append_target_section(
805 &mut lines,
806 "External call targets",
807 input.external_targets,
808 input.top_n,
809 );
810
811 if let Some(summary) = input.bridge_summary {
812 lines.push(String::new());
813 lines.push("RELATES_TO_CODE bridges".to_string());
814 lines.push(format!(
815 "- {} inferred read-only edge(s)",
816 summary.edge_count
817 ));
818 if let Some(range) = &summary.confidence_range {
819 lines.push(format!("- Confidence: {:.3}..{:.3}", range.min, range.max));
820 }
821 }
822
823 if !input.degradation_details.is_empty() {
824 lines.push(String::new());
825 lines.push("Degradation".to_string());
826 for detail in input.degradation_details {
827 lines.push(format!("- {}: {}", detail.input, detail.detail));
828 }
829 }
830
831 lines.join("\n")
832}
833
834fn append_hotspot_section(
835 lines: &mut Vec<String>,
836 title: &str,
837 hotspots: &[GraphHotspot],
838 top_n: usize,
839) {
840 if hotspots.is_empty() {
841 return;
842 }
843 lines.push(String::new());
844 lines.push(title.to_string());
845 for hotspot in hotspots.iter().take(top_n) {
846 lines.push(format!(
847 "- {} ({}, degree {})",
848 hotspot.name, hotspot.node_type, hotspot.degree
849 ));
850 }
851}
852
853fn append_target_section(
854 lines: &mut Vec<String>,
855 title: &str,
856 targets: &[TargetFrequency],
857 top_n: usize,
858) {
859 if targets.is_empty() {
860 return;
861 }
862 lines.push(String::new());
863 lines.push(title.to_string());
864 for target in targets.iter().take(top_n) {
865 lines.push(format!("- {} ({})", target.name, target.count));
866 }
867}
868
869fn named_counts_inline(counts: &BTreeMap<String, usize>) -> String {
870 counts
871 .iter()
872 .map(|(name, count)| format!("{name}={count}"))
873 .collect::<Vec<_>>()
874 .join(", ")
875}
876
877fn sort_hotspots(hotspots: &mut [GraphHotspot]) {
878 hotspots.sort_by(|left, right| {
879 right
880 .degree
881 .cmp(&left.degree)
882 .then_with(|| left.name.cmp(&right.name))
883 .then_with(|| left.id.cmp(&right.id))
884 });
885}
886
887fn is_symbol_node(node_type: &str) -> bool {
888 !matches!(node_type, "file" | "module" | "unresolved" | "external")
889}
890
891fn inferred_bridge_metadata(mut metadata: ProjectionMetadata) -> ProjectionMetadata {
892 metadata.provenance = ProjectionProvenance::Inferred;
893 metadata
894}
895
896fn row_string(row: &Row, keys: &[&str]) -> Option<String> {
897 keys.iter()
898 .find_map(|key| row.get(*key).and_then(Value::as_str))
899 .filter(|value| !value.is_empty())
900 .map(ToOwned::to_owned)
901}
902
903fn row_usize(row: &Row, keys: &[&str]) -> Option<usize> {
904 keys.iter()
905 .find_map(|key| row.get(*key))
906 .and_then(|value| {
907 value
908 .as_u64()
909 .or_else(|| value.as_i64().and_then(|value| value.try_into().ok()))
910 })
911 .map(|value| value as usize)
912}
913
914fn row_f64(row: &Row, keys: &[&str]) -> Option<f64> {
915 keys.iter()
916 .find_map(|key| row.get(*key))
917 .and_then(Value::as_f64)
918}
919
920fn now_iso8601() -> String {
921 let dur = SystemTime::now()
922 .duration_since(UNIX_EPOCH)
923 .unwrap_or_default();
924 let secs = dur.as_secs();
925 let micros = dur.subsec_micros();
926
927 let (year, month, day) = days_to_ymd(secs / 86400);
928 let daytime = secs % 86400;
929 let hour = daytime / 3600;
930 let minute = (daytime % 3600) / 60;
931 let second = daytime % 60;
932
933 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{micros:06}+00:00")
934}
935
936fn days_to_ymd(days: u64) -> (u64, u64, u64) {
937 let z = days as i64 + 719468;
938 let era = if z >= 0 { z } else { z - 146096 } / 146097;
939 let doe = (z - era * 146097) as u64;
940 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
941 let y = yoe as i64 + era * 400;
942 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
943 let mp = (5 * doy + 2) / 153;
944 let day = doy - (153 * mp + 2) / 5 + 1;
945 let month = if mp < 10 { mp + 3 } else { mp - 9 };
946 let year = if month <= 2 { y + 1 } else { y };
947 (year as u64, month, day)
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use crate::config::{CodeVectorSettings, Context};
954 use crate::models::{ProjectionMetadata, ProjectionProvenance};
955 use std::path::PathBuf;
956
957 #[test]
958 fn report_shape() {
959 let snapshot = ReportGraphSnapshot {
960 nodes: vec![
961 ReportNode::new("src/lib.rs", "src/lib.rs", "file"),
962 ReportNode::new("mod:api", "api", "module"),
963 ReportNode::new("sym:handler", "handler", "function").with_file_path("src/lib.rs"),
964 ReportNode::new("sym:parse", "parse", "function").with_file_path("src/lib.rs"),
965 ReportNode::new("unresolved:do_work", "do_work", "unresolved"),
966 ReportNode::new("external:serde_json", "serde_json", "external"),
967 ],
968 code_edges: vec![
969 ReportCodeEdge::new("src/lib.rs", "sym:handler", "DEFINES"),
970 ReportCodeEdge::new("src/lib.rs", "mod:api", "IMPORTS"),
971 ReportCodeEdge::new("sym:handler", "sym:parse", "CALLS"),
972 ReportCodeEdge::new("sym:parse", "unresolved:do_work", "CALLS"),
973 ReportCodeEdge::new("sym:handler", "external:serde_json", "CALLS"),
974 ],
975 bridge_edges: BridgeEdgeInput::available(vec![BridgeEdgeHypothesis::inferred(
976 "memory-1",
977 "sym:handler",
978 RELATES_TO_CODE,
979 "gobby-memory",
980 Some(0.72),
981 )]),
982 };
983
984 let report = generate_report_from_snapshot("project-1", "2026-05-28T00:00:00Z", snapshot);
985 let json = serde_json::to_value(&report).expect("report serializes");
986
987 assert_eq!(json["project_id"], "project-1");
988 assert_eq!(json["summary"]["node_count"], 6);
989 assert_eq!(json["summary"]["edge_count"], 5);
990 assert_eq!(json["summary"]["code_edge_counts"]["CALLS"], 3);
991 assert_eq!(json["hotspots"]["high_degree_files"][0]["id"], "src/lib.rs");
992 assert_eq!(
993 json["hotspots"]["incoming_call_hotspots"][0]["id"],
994 "sym:parse"
995 );
996 assert_eq!(json["unresolved_targets"][0]["name"], "do_work");
997 assert_eq!(json["external_targets"][0]["name"], "serde_json");
998 assert_eq!(json["bridge_summary"]["relation"], RELATES_TO_CODE);
999 assert_eq!(json["bridge_summary"]["confidence_range"]["min"], 0.72);
1000 assert!(json["markdown"].as_str().unwrap().contains("project-1"));
1001 assert!(
1002 !json["suggested_investigation_questions"]
1003 .as_array()
1004 .unwrap()
1005 .is_empty()
1006 );
1007 }
1008
1009 #[test]
1010 fn bridge_edges_are_read_only() {
1011 let edge = BridgeEdgeHypothesis::new(
1012 "memory-1",
1013 "symbol-1",
1014 RELATES_TO_CODE,
1015 ProjectionMetadata::gcode_extracted(),
1016 );
1017
1018 assert!(edge.read_only);
1019 assert_eq!(edge.label, "inferred hypothesis");
1020 assert_eq!(edge.metadata.provenance, ProjectionProvenance::Inferred);
1021
1022 let snapshot = ReportGraphSnapshot {
1023 nodes: vec![ReportNode::new("symbol-1", "handler", "function")],
1024 code_edges: vec![],
1025 bridge_edges: BridgeEdgeInput::available(vec![edge]),
1026 };
1027 let report = generate_report_from_snapshot("project-1", "2026-05-28T00:00:00Z", snapshot);
1028 let json = serde_json::to_value(&report).expect("report serializes");
1029
1030 assert_eq!(json["bridge_edges"][0]["read_only"], true);
1031 assert_eq!(
1032 json["bridge_edges"][0]["metadata"]["provenance"],
1033 "INFERRED"
1034 );
1035 }
1036
1037 #[test]
1038 fn report_degradation_contract() {
1039 let ctx = Context {
1040 database_url: "postgresql://localhost/unavailable".to_string(),
1041 project_root: PathBuf::from("/tmp/project"),
1042 project_id: "project-1".to_string(),
1043 quiet: true,
1044 falkordb: None,
1045 qdrant: None,
1046 embedding: None,
1047 code_vectors: CodeVectorSettings::default(),
1048 daemon_url: None,
1049 };
1050 let err = generate_report(&ctx).expect_err("missing graph service is required");
1051 assert_eq!(err, ProjectGraphReportError::GraphServiceNotConfigured);
1052
1053 let report = generate_report_from_snapshot(
1054 "project-1",
1055 "2026-05-28T00:00:00Z",
1056 ReportGraphSnapshot {
1057 nodes: vec![ReportNode::new("symbol-1", "handler", "function")],
1058 code_edges: vec![],
1059 bridge_edges: BridgeEdgeInput::unavailable("bridge edge query timed out"),
1060 },
1061 );
1062
1063 assert_eq!(report.summary.node_count, 1);
1064 assert_eq!(report.degradation_details.len(), 1);
1065 assert_eq!(report.degradation_details[0].input, RELATES_TO_CODE);
1066 assert!(!report.degradation_details[0].required);
1067 }
1068
1069 #[test]
1070 fn bridge_edges_are_hypotheses() {
1071 let edge = BridgeEdgeHypothesis::inferred(
1072 "memory-1",
1073 "symbol-1",
1074 RELATES_TO_CODE,
1075 "gobby-memory",
1076 Some(0.72),
1077 );
1078
1079 assert_eq!(edge.label, "inferred hypothesis");
1080 assert_eq!(edge.metadata.provenance, ProjectionProvenance::Inferred);
1081 assert!(edge.metadata.is_hypothesis());
1082
1083 let mut report = empty_report("project-1");
1084 report.bridge_edges.push(edge);
1085
1086 let json = serde_json::to_value(&report).expect("report serializes");
1087 assert_eq!(json["bridge_edges"][0]["label"], "inferred hypothesis");
1088 assert_eq!(
1089 json["bridge_edges"][0]["metadata"]["provenance"],
1090 "INFERRED"
1091 );
1092 }
1093}