use std::collections::{BTreeMap, HashMap};
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
use gobby_core::degradation::ServiceState;
use gobby_core::falkor::{GraphClient, Row};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::config::Context;
use crate::graph::typed_query;
use crate::models::{ProjectionMetadata, ProjectionProvenance};
const RELATES_TO_CODE: &str = "RELATES_TO_CODE";
const DEFAULT_TOP_LIMIT: usize = 10;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BridgeEdgeHypothesis {
pub source_id: String,
pub target_symbol_id: String,
pub relation: String,
pub label: String,
pub read_only: bool,
pub metadata: ProjectionMetadata,
}
impl BridgeEdgeHypothesis {
pub fn new(
source_id: impl Into<String>,
target_symbol_id: impl Into<String>,
relation: impl Into<String>,
metadata: ProjectionMetadata,
) -> Self {
Self {
source_id: source_id.into(),
target_symbol_id: target_symbol_id.into(),
relation: relation.into(),
label: "inferred hypothesis".to_string(),
read_only: true,
metadata: inferred_bridge_metadata(metadata),
}
}
pub fn inferred(
source_id: impl Into<String>,
target_symbol_id: impl Into<String>,
relation: impl Into<String>,
source_system: impl Into<String>,
confidence: Option<f64>,
) -> Self {
Self::new(
source_id,
target_symbol_id,
relation,
ProjectionMetadata::inferred(source_system, confidence),
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectGraphReport {
pub project_id: String,
pub generated_at: String,
pub summary: GraphReportSummary,
pub hotspots: GraphReportHotspots,
pub unresolved_targets: Vec<TargetFrequency>,
pub external_targets: Vec<TargetFrequency>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bridge_summary: Option<BridgeReportSummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bridge_edges: Vec<BridgeEdgeHypothesis>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub degradation_details: Vec<ReportDegradation>,
pub suggested_investigation_questions: Vec<String>,
pub markdown: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProjectGraphReportOptions {
pub top_n: usize,
}
impl Default for ProjectGraphReportOptions {
fn default() -> Self {
Self {
top_n: DEFAULT_TOP_LIMIT,
}
}
}
impl ProjectGraphReportOptions {
fn normalized(self) -> Self {
Self {
top_n: self.top_n.max(1),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphReportSummary {
pub node_count: usize,
pub edge_count: usize,
pub node_counts_by_type: BTreeMap<String, usize>,
pub code_edge_counts: BTreeMap<String, usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphReportHotspots {
pub high_degree_files: Vec<GraphHotspot>,
pub high_degree_symbols: Vec<GraphHotspot>,
pub high_degree_modules: Vec<GraphHotspot>,
pub incoming_call_hotspots: Vec<GraphHotspot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GraphHotspot {
pub id: String,
pub name: String,
#[serde(rename = "type")]
pub node_type: String,
pub degree: usize,
pub incoming: usize,
pub outgoing: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetFrequency {
pub id: String,
pub name: String,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BridgeReportSummary {
pub relation: String,
pub edge_count: usize,
pub inferred: bool,
pub read_only: bool,
pub source_system_counts: Vec<NamedCount>,
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence_range: Option<ConfidenceRange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NamedCount {
pub name: String,
pub count: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfidenceRange {
pub min: f64,
pub max: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportDegradation {
pub input: String,
pub required: bool,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectGraphReportError {
GraphServiceNotConfigured,
GraphServiceUnreachable { message: String },
GraphQueryFailed { message: String },
}
impl fmt::Display for ProjectGraphReportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GraphServiceNotConfigured => {
f.write_str("FalkorDB is not configured; project graph report requires FalkorDB")
}
Self::GraphServiceUnreachable { message } => write!(
f,
"FalkorDB is unreachable; project graph report requires FalkorDB: {message}"
),
Self::GraphQueryFailed { message } => {
write!(f, "project graph report query failed: {message}")
}
}
}
}
impl std::error::Error for ProjectGraphReportError {}
#[derive(Debug, Clone, Default, PartialEq)]
struct ReportGraphSnapshot {
nodes: Vec<ReportNode>,
code_edges: Vec<ReportCodeEdge>,
bridge_edges: BridgeEdgeInput,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReportNode {
id: String,
name: String,
node_type: String,
file_path: Option<String>,
}
impl ReportNode {
fn new(id: impl Into<String>, name: impl Into<String>, node_type: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
node_type: node_type.into(),
file_path: None,
}
}
#[cfg(test)]
fn with_file_path(mut self, file_path: impl Into<String>) -> Self {
self.file_path = Some(file_path.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ReportCodeEdge {
source: String,
target: String,
edge_type: String,
}
impl ReportCodeEdge {
fn new(
source: impl Into<String>,
target: impl Into<String>,
edge_type: impl Into<String>,
) -> Self {
Self {
source: source.into(),
target: target.into(),
edge_type: edge_type.into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum BridgeEdgeInput {
Available(Vec<BridgeEdgeHypothesis>),
Unavailable(String),
}
impl BridgeEdgeInput {
fn available(edges: Vec<BridgeEdgeHypothesis>) -> Self {
Self::Available(edges)
}
fn unavailable(reason: impl Into<String>) -> Self {
Self::Unavailable(reason.into())
}
}
impl Default for BridgeEdgeInput {
fn default() -> Self {
Self::Available(vec![])
}
}
#[derive(Debug, Clone, Copy, Default)]
struct DegreeStats {
incoming: usize,
outgoing: usize,
}
pub fn generate_report(ctx: &Context) -> Result<ProjectGraphReport, ProjectGraphReportError> {
generate_report_with_options(ctx, ProjectGraphReportOptions::default())
}
pub fn generate_report_with_options(
ctx: &Context,
options: ProjectGraphReportOptions,
) -> Result<ProjectGraphReport, ProjectGraphReportError> {
let Some(config) = ctx.falkordb.as_ref() else {
return Err(ProjectGraphReportError::GraphServiceNotConfigured);
};
let connection_config = config.connection_config();
let result = gobby_core::falkor::with_graph(
Some(&connection_config),
&config.graph_name,
ReportGraphSnapshot::default(),
|client| load_report_snapshot(client, &ctx.project_id),
);
match result {
Ok((snapshot, ServiceState::Available)) => Ok(generate_report_from_snapshot_with_options(
&ctx.project_id,
now_iso8601(),
snapshot,
options,
)),
Ok((_, ServiceState::NotConfigured)) => {
Err(ProjectGraphReportError::GraphServiceNotConfigured)
}
Ok((_, ServiceState::Unreachable { message })) => {
Err(ProjectGraphReportError::GraphServiceUnreachable { message })
}
Err(error) => Err(ProjectGraphReportError::GraphQueryFailed {
message: error.to_string(),
}),
}
}
pub fn empty_report(project_id: impl Into<String>) -> ProjectGraphReport {
generate_report_from_snapshot(project_id, now_iso8601(), ReportGraphSnapshot::default())
}
fn generate_report_from_snapshot(
project_id: impl Into<String>,
generated_at: impl Into<String>,
snapshot: ReportGraphSnapshot,
) -> ProjectGraphReport {
generate_report_from_snapshot_with_options(
project_id,
generated_at,
snapshot,
ProjectGraphReportOptions::default(),
)
}
fn generate_report_from_snapshot_with_options(
project_id: impl Into<String>,
generated_at: impl Into<String>,
snapshot: ReportGraphSnapshot,
options: ProjectGraphReportOptions,
) -> ProjectGraphReport {
let options = options.normalized();
let project_id = project_id.into();
let generated_at = generated_at.into();
let node_by_id = snapshot
.nodes
.iter()
.map(|node| (node.id.as_str(), node))
.collect::<HashMap<_, _>>();
let summary = summarize_graph(&snapshot.nodes, &snapshot.code_edges);
let hotspots = summarize_hotspots(&snapshot.nodes, &snapshot.code_edges, options.top_n);
let unresolved_targets = target_frequencies(
&snapshot.code_edges,
&node_by_id,
"unresolved",
options.top_n,
);
let external_targets =
target_frequencies(&snapshot.code_edges, &node_by_id, "external", options.top_n);
let (bridge_edges, mut degradation_details) = match snapshot.bridge_edges {
BridgeEdgeInput::Available(edges) => (normalize_bridge_edges(edges), vec![]),
BridgeEdgeInput::Unavailable(reason) => (
vec![],
vec![ReportDegradation {
input: RELATES_TO_CODE.to_string(),
required: false,
detail: reason,
}],
),
};
let bridge_summary = summarize_bridge_edges(&bridge_edges);
let suggested_investigation_questions = suggested_questions(
&hotspots,
&unresolved_targets,
&external_targets,
bridge_summary.as_ref(),
°radation_details,
);
let markdown = render_markdown(RenderMarkdownInput {
project_id: &project_id,
generated_at: &generated_at,
summary: &summary,
hotspots: &hotspots,
unresolved_targets: &unresolved_targets,
external_targets: &external_targets,
bridge_summary: bridge_summary.as_ref(),
degradation_details: °radation_details,
top_n: options.top_n,
});
degradation_details.sort_by(|left, right| left.input.cmp(&right.input));
ProjectGraphReport {
project_id,
generated_at,
summary,
hotspots,
unresolved_targets,
external_targets,
bridge_summary,
bridge_edges,
degradation_details,
suggested_investigation_questions,
markdown,
}
}
fn load_report_snapshot(
client: &mut GraphClient,
project_id: &str,
) -> anyhow::Result<ReportGraphSnapshot> {
let (query, params) = report_nodes_query(project_id);
let nodes = client
.query(&query, Some(params))?
.iter()
.filter_map(row_to_report_node)
.collect::<Vec<_>>();
let (query, params) = report_code_edges_query(project_id);
let code_edges = client
.query(&query, Some(params))?
.iter()
.filter_map(row_to_report_code_edge)
.collect::<Vec<_>>();
let (query, params) = report_bridge_edges_query(project_id);
let bridge_edges = match client.query(&query, Some(params)) {
Ok(rows) => BridgeEdgeInput::available(
rows.iter()
.filter_map(row_to_bridge_edge_hypothesis)
.collect(),
),
Err(error) => BridgeEdgeInput::unavailable(format!("bridge edge query failed: {error}")),
};
Ok(ReportGraphSnapshot {
nodes,
code_edges,
bridge_edges,
})
}
fn report_nodes_query(project_id: &str) -> (String, HashMap<String, String>) {
(
"MATCH (n {project: $project}) \
WHERE n:CodeFile OR n:CodeSymbol OR n:CodeModule OR n:UnresolvedCallee OR n:ExternalSymbol \
RETURN coalesce(n.id, n.path, n.name) AS id, \
coalesce(n.name, n.path, n.id) AS name, \
CASE \
WHEN n:CodeFile THEN 'file' \
WHEN n:CodeModule THEN 'module' \
WHEN n:CodeSymbol THEN coalesce(n.kind, 'symbol') \
WHEN n:UnresolvedCallee THEN 'unresolved' \
WHEN n:ExternalSymbol THEN 'external' \
ELSE 'node' \
END AS node_type, \
coalesce(n.file_path, n.path) AS file_path"
.to_string(),
typed_query::string_params(&[("project", project_id)]),
)
}
fn report_code_edges_query(project_id: &str) -> (String, HashMap<String, String>) {
(
"MATCH (source {project: $project})-[r]->(target {project: $project}) \
WHERE type(r) IN ['DEFINES', 'IMPORTS', 'CALLS'] \
RETURN coalesce(source.id, source.path, source.name) AS source, \
coalesce(target.id, target.path, target.name) AS target, \
type(r) AS edge_type"
.to_string(),
typed_query::string_params(&[("project", project_id)]),
)
}
fn report_bridge_edges_query(project_id: &str) -> (String, HashMap<String, String>) {
(
"MATCH (source)-[r:RELATES_TO_CODE]->(target:CodeSymbol {project: $project}) \
RETURN coalesce(source.id, source.uuid, source.name) AS source_id, \
target.id AS target_symbol_id, \
'RELATES_TO_CODE' AS relation, \
r.provenance AS provenance, \
r.confidence AS confidence, \
coalesce(r.source_system, 'gobby-memory') AS source_system, \
r.source_file_path AS source_file_path, \
r.source_line AS source_line, \
r.source_symbol_id AS source_symbol_id, \
r.matching_method AS matching_method"
.to_string(),
typed_query::string_params(&[("project", project_id)]),
)
}
fn row_to_report_node(row: &Row) -> Option<ReportNode> {
let id = row_string(row, &["id"])?;
let name = row_string(row, &["name"]).unwrap_or_else(|| id.clone());
let node_type = row_string(row, &["node_type"]).unwrap_or_else(|| "node".to_string());
let mut node = ReportNode::new(id, name, node_type);
node.file_path = row_string(row, &["file_path"]);
Some(node)
}
fn row_to_report_code_edge(row: &Row) -> Option<ReportCodeEdge> {
let source = row_string(row, &["source"])?;
let target = row_string(row, &["target"])?;
let edge_type = row_string(row, &["edge_type"]).unwrap_or_else(|| "CALLS".to_string());
Some(ReportCodeEdge::new(source, target, edge_type))
}
fn row_to_bridge_edge_hypothesis(row: &Row) -> Option<BridgeEdgeHypothesis> {
let source_id = row_string(row, &["source_id"])?;
let target_symbol_id = row_string(row, &["target_symbol_id"])?;
let relation = row_string(row, &["relation"]).unwrap_or_else(|| RELATES_TO_CODE.to_string());
let source_system =
row_string(row, &["source_system"]).unwrap_or_else(|| "gobby-memory".to_string());
let mut metadata = ProjectionMetadata::new(
row_string(row, &["provenance"])
.and_then(|value| ProjectionProvenance::from_wire_value(&value))
.unwrap_or(ProjectionProvenance::Inferred),
source_system,
);
metadata.confidence = row_f64(row, &["confidence"]);
metadata.source_file_path = row_string(row, &["source_file_path"]);
metadata.source_line = row_usize(row, &["source_line"]);
metadata.source_symbol_id = row_string(row, &["source_symbol_id"]);
metadata.matching_method = row_string(row, &["matching_method"]);
Some(BridgeEdgeHypothesis::new(
source_id,
target_symbol_id,
relation,
metadata,
))
}
fn summarize_graph(nodes: &[ReportNode], edges: &[ReportCodeEdge]) -> GraphReportSummary {
let mut node_counts_by_type = BTreeMap::new();
for node in nodes {
*node_counts_by_type
.entry(node.node_type.clone())
.or_insert(0) += 1;
}
let mut code_edge_counts = BTreeMap::new();
for edge in edges {
*code_edge_counts.entry(edge.edge_type.clone()).or_insert(0) += 1;
}
GraphReportSummary {
node_count: nodes.len(),
edge_count: edges.len(),
node_counts_by_type,
code_edge_counts,
}
}
fn summarize_hotspots(
nodes: &[ReportNode],
edges: &[ReportCodeEdge],
top_n: usize,
) -> GraphReportHotspots {
let mut degree = HashMap::<&str, DegreeStats>::new();
let mut incoming_calls = HashMap::<&str, usize>::new();
for edge in edges {
degree.entry(&edge.source).or_default().outgoing += 1;
degree.entry(&edge.target).or_default().incoming += 1;
if edge.edge_type == "CALLS" {
*incoming_calls.entry(&edge.target).or_insert(0) += 1;
}
}
GraphReportHotspots {
high_degree_files: top_hotspots(nodes, °ree, top_n, |node| node.node_type == "file"),
high_degree_symbols: top_hotspots(nodes, °ree, top_n, |node| {
is_symbol_node(&node.node_type)
}),
high_degree_modules: top_hotspots(nodes, °ree, top_n, |node| node.node_type == "module"),
incoming_call_hotspots: top_incoming_call_hotspots(nodes, &incoming_calls, top_n),
}
}
fn top_hotspots(
nodes: &[ReportNode],
degree: &HashMap<&str, DegreeStats>,
top_n: usize,
include: impl Fn(&ReportNode) -> bool,
) -> Vec<GraphHotspot> {
let mut hotspots = nodes
.iter()
.filter(|node| include(node))
.filter_map(|node| {
let stats = degree.get(node.id.as_str())?;
let total = stats.incoming + stats.outgoing;
(total > 0).then(|| GraphHotspot {
id: node.id.clone(),
name: node.name.clone(),
node_type: node.node_type.clone(),
degree: total,
incoming: stats.incoming,
outgoing: stats.outgoing,
file_path: node.file_path.clone(),
})
})
.collect::<Vec<_>>();
sort_hotspots(&mut hotspots);
hotspots.truncate(top_n);
hotspots
}
fn top_incoming_call_hotspots(
nodes: &[ReportNode],
incoming_calls: &HashMap<&str, usize>,
top_n: usize,
) -> Vec<GraphHotspot> {
let mut hotspots = nodes
.iter()
.filter(|node| is_symbol_node(&node.node_type))
.filter_map(|node| {
let incoming = incoming_calls.get(node.id.as_str()).copied().unwrap_or(0);
(incoming > 0).then(|| GraphHotspot {
id: node.id.clone(),
name: node.name.clone(),
node_type: node.node_type.clone(),
degree: incoming,
incoming,
outgoing: 0,
file_path: node.file_path.clone(),
})
})
.collect::<Vec<_>>();
sort_hotspots(&mut hotspots);
hotspots.truncate(top_n);
hotspots
}
fn target_frequencies(
edges: &[ReportCodeEdge],
node_by_id: &HashMap<&str, &ReportNode>,
target_type: &str,
top_n: usize,
) -> Vec<TargetFrequency> {
let mut counts = BTreeMap::<String, TargetFrequency>::new();
for edge in edges.iter().filter(|edge| edge.edge_type == "CALLS") {
let Some(node) = node_by_id.get(edge.target.as_str()) else {
continue;
};
if node.node_type != target_type {
continue;
}
let entry = counts
.entry(node.id.clone())
.or_insert_with(|| TargetFrequency {
id: node.id.clone(),
name: node.name.clone(),
count: 0,
});
entry.count += 1;
}
let mut frequencies = counts.into_values().collect::<Vec<_>>();
frequencies.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
frequencies.truncate(top_n);
frequencies
}
fn summarize_bridge_edges(edges: &[BridgeEdgeHypothesis]) -> Option<BridgeReportSummary> {
if edges.is_empty() {
return None;
}
let mut source_counts = BTreeMap::<String, usize>::new();
let mut confidence_min = f64::INFINITY;
let mut confidence_max = f64::NEG_INFINITY;
let mut has_confidence = false;
for edge in edges {
*source_counts
.entry(edge.metadata.source_system.clone())
.or_insert(0) += 1;
if let Some(confidence) = edge.metadata.confidence
&& confidence.is_finite()
{
confidence_min = confidence_min.min(confidence);
confidence_max = confidence_max.max(confidence);
has_confidence = true;
}
}
let source_system_counts = source_counts
.into_iter()
.map(|(name, count)| NamedCount { name, count })
.collect();
Some(BridgeReportSummary {
relation: RELATES_TO_CODE.to_string(),
edge_count: edges.len(),
inferred: true,
read_only: true,
source_system_counts,
confidence_range: has_confidence.then_some(ConfidenceRange {
min: confidence_min,
max: confidence_max,
}),
})
}
fn normalize_bridge_edges(edges: Vec<BridgeEdgeHypothesis>) -> Vec<BridgeEdgeHypothesis> {
edges
.into_iter()
.map(|edge| {
BridgeEdgeHypothesis::new(
edge.source_id,
edge.target_symbol_id,
edge.relation,
edge.metadata,
)
})
.collect()
}
fn suggested_questions(
hotspots: &GraphReportHotspots,
unresolved_targets: &[TargetFrequency],
external_targets: &[TargetFrequency],
bridge_summary: Option<&BridgeReportSummary>,
degradation_details: &[ReportDegradation],
) -> Vec<String> {
let mut questions =
vec!["Which high-degree files or symbols should be reviewed before refactors?".to_string()];
if !hotspots.incoming_call_hotspots.is_empty() {
questions.push("Which incoming-call hotspots define the largest blast radius?".to_string());
}
if !unresolved_targets.is_empty() || !external_targets.is_empty() {
questions.push(
"Which unresolved or external call targets should be resolved first?".to_string(),
);
}
if bridge_summary.is_some() {
questions
.push("Which inferred RELATES_TO_CODE bridges need human confirmation?".to_string());
}
if !degradation_details.is_empty() {
questions.push(
"Which degraded optional inputs should be restored for the next report?".to_string(),
);
}
questions
}
struct RenderMarkdownInput<'a> {
project_id: &'a str,
generated_at: &'a str,
summary: &'a GraphReportSummary,
hotspots: &'a GraphReportHotspots,
unresolved_targets: &'a [TargetFrequency],
external_targets: &'a [TargetFrequency],
bridge_summary: Option<&'a BridgeReportSummary>,
degradation_details: &'a [ReportDegradation],
top_n: usize,
}
fn render_markdown(input: RenderMarkdownInput<'_>) -> String {
let mut lines = vec![
"# Project Graph Report".to_string(),
String::new(),
format!("- Project: {}", input.project_id),
format!("- Generated: {}", input.generated_at),
format!("- Nodes: {}", input.summary.node_count),
format!("- Edges: {}", input.summary.edge_count),
];
if !input.summary.code_edge_counts.is_empty() {
lines.push(format!(
"- Code edges: {}",
named_counts_inline(&input.summary.code_edge_counts)
));
}
append_hotspot_section(
&mut lines,
"High-degree files",
&input.hotspots.high_degree_files,
input.top_n,
);
append_hotspot_section(
&mut lines,
"High-degree symbols",
&input.hotspots.high_degree_symbols,
input.top_n,
);
append_hotspot_section(
&mut lines,
"Incoming-call hotspots",
&input.hotspots.incoming_call_hotspots,
input.top_n,
);
append_target_section(
&mut lines,
"Unresolved call targets",
input.unresolved_targets,
input.top_n,
);
append_target_section(
&mut lines,
"External call targets",
input.external_targets,
input.top_n,
);
if let Some(summary) = input.bridge_summary {
lines.push(String::new());
lines.push("RELATES_TO_CODE bridges".to_string());
lines.push(format!(
"- {} inferred read-only edge(s)",
summary.edge_count
));
if let Some(range) = &summary.confidence_range {
lines.push(format!("- Confidence: {:.3}..{:.3}", range.min, range.max));
}
}
if !input.degradation_details.is_empty() {
lines.push(String::new());
lines.push("Degradation".to_string());
for detail in input.degradation_details {
lines.push(format!("- {}: {}", detail.input, detail.detail));
}
}
lines.join("\n")
}
fn append_hotspot_section(
lines: &mut Vec<String>,
title: &str,
hotspots: &[GraphHotspot],
top_n: usize,
) {
if hotspots.is_empty() {
return;
}
lines.push(String::new());
lines.push(title.to_string());
for hotspot in hotspots.iter().take(top_n) {
lines.push(format!(
"- {} ({}, degree {})",
hotspot.name, hotspot.node_type, hotspot.degree
));
}
}
fn append_target_section(
lines: &mut Vec<String>,
title: &str,
targets: &[TargetFrequency],
top_n: usize,
) {
if targets.is_empty() {
return;
}
lines.push(String::new());
lines.push(title.to_string());
for target in targets.iter().take(top_n) {
lines.push(format!("- {} ({})", target.name, target.count));
}
}
fn named_counts_inline(counts: &BTreeMap<String, usize>) -> String {
counts
.iter()
.map(|(name, count)| format!("{name}={count}"))
.collect::<Vec<_>>()
.join(", ")
}
fn sort_hotspots(hotspots: &mut [GraphHotspot]) {
hotspots.sort_by(|left, right| {
right
.degree
.cmp(&left.degree)
.then_with(|| left.name.cmp(&right.name))
.then_with(|| left.id.cmp(&right.id))
});
}
fn is_symbol_node(node_type: &str) -> bool {
!matches!(node_type, "file" | "module" | "unresolved" | "external")
}
fn inferred_bridge_metadata(mut metadata: ProjectionMetadata) -> ProjectionMetadata {
metadata.provenance = ProjectionProvenance::Inferred;
metadata
}
fn row_string(row: &Row, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| row.get(*key).and_then(Value::as_str))
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn row_usize(row: &Row, keys: &[&str]) -> Option<usize> {
keys.iter()
.find_map(|key| row.get(*key))
.and_then(|value| {
value
.as_u64()
.or_else(|| value.as_i64().and_then(|value| value.try_into().ok()))
})
.map(|value| value as usize)
}
fn row_f64(row: &Row, keys: &[&str]) -> Option<f64> {
keys.iter()
.find_map(|key| row.get(*key))
.and_then(Value::as_f64)
}
fn now_iso8601() -> String {
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
let micros = dur.subsec_micros();
let (year, month, day) = days_to_ymd(secs / 86400);
let daytime = secs % 86400;
let hour = daytime / 3600;
let minute = (daytime % 3600) / 60;
let second = daytime % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{micros:06}+00:00")
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days as i64 + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if month <= 2 { y + 1 } else { y };
(year as u64, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CodeVectorSettings, Context};
use crate::models::{ProjectionMetadata, ProjectionProvenance};
use std::path::PathBuf;
#[test]
fn report_shape() {
let snapshot = ReportGraphSnapshot {
nodes: vec![
ReportNode::new("src/lib.rs", "src/lib.rs", "file"),
ReportNode::new("mod:api", "api", "module"),
ReportNode::new("sym:handler", "handler", "function").with_file_path("src/lib.rs"),
ReportNode::new("sym:parse", "parse", "function").with_file_path("src/lib.rs"),
ReportNode::new("unresolved:do_work", "do_work", "unresolved"),
ReportNode::new("external:serde_json", "serde_json", "external"),
],
code_edges: vec![
ReportCodeEdge::new("src/lib.rs", "sym:handler", "DEFINES"),
ReportCodeEdge::new("src/lib.rs", "mod:api", "IMPORTS"),
ReportCodeEdge::new("sym:handler", "sym:parse", "CALLS"),
ReportCodeEdge::new("sym:parse", "unresolved:do_work", "CALLS"),
ReportCodeEdge::new("sym:handler", "external:serde_json", "CALLS"),
],
bridge_edges: BridgeEdgeInput::available(vec![BridgeEdgeHypothesis::inferred(
"memory-1",
"sym:handler",
RELATES_TO_CODE,
"gobby-memory",
Some(0.72),
)]),
};
let report = generate_report_from_snapshot("project-1", "2026-05-28T00:00:00Z", snapshot);
let json = serde_json::to_value(&report).expect("report serializes");
assert_eq!(json["project_id"], "project-1");
assert_eq!(json["summary"]["node_count"], 6);
assert_eq!(json["summary"]["edge_count"], 5);
assert_eq!(json["summary"]["code_edge_counts"]["CALLS"], 3);
assert_eq!(json["hotspots"]["high_degree_files"][0]["id"], "src/lib.rs");
assert_eq!(
json["hotspots"]["incoming_call_hotspots"][0]["id"],
"sym:parse"
);
assert_eq!(json["unresolved_targets"][0]["name"], "do_work");
assert_eq!(json["external_targets"][0]["name"], "serde_json");
assert_eq!(json["bridge_summary"]["relation"], RELATES_TO_CODE);
assert_eq!(json["bridge_summary"]["confidence_range"]["min"], 0.72);
assert!(json["markdown"].as_str().unwrap().contains("project-1"));
assert!(
!json["suggested_investigation_questions"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn bridge_edges_are_read_only() {
let edge = BridgeEdgeHypothesis::new(
"memory-1",
"symbol-1",
RELATES_TO_CODE,
ProjectionMetadata::gcode_extracted(),
);
assert!(edge.read_only);
assert_eq!(edge.label, "inferred hypothesis");
assert_eq!(edge.metadata.provenance, ProjectionProvenance::Inferred);
let snapshot = ReportGraphSnapshot {
nodes: vec![ReportNode::new("symbol-1", "handler", "function")],
code_edges: vec![],
bridge_edges: BridgeEdgeInput::available(vec![edge]),
};
let report = generate_report_from_snapshot("project-1", "2026-05-28T00:00:00Z", snapshot);
let json = serde_json::to_value(&report).expect("report serializes");
assert_eq!(json["bridge_edges"][0]["read_only"], true);
assert_eq!(
json["bridge_edges"][0]["metadata"]["provenance"],
"INFERRED"
);
}
#[test]
fn report_degradation_contract() {
let ctx = Context {
database_url: "postgresql://localhost/unavailable".to_string(),
project_root: PathBuf::from("/tmp/project"),
project_id: "project-1".to_string(),
quiet: true,
falkordb: None,
qdrant: None,
embedding: None,
code_vectors: CodeVectorSettings::default(),
daemon_url: None,
};
let err = generate_report(&ctx).expect_err("missing graph service is required");
assert_eq!(err, ProjectGraphReportError::GraphServiceNotConfigured);
let report = generate_report_from_snapshot(
"project-1",
"2026-05-28T00:00:00Z",
ReportGraphSnapshot {
nodes: vec![ReportNode::new("symbol-1", "handler", "function")],
code_edges: vec![],
bridge_edges: BridgeEdgeInput::unavailable("bridge edge query timed out"),
},
);
assert_eq!(report.summary.node_count, 1);
assert_eq!(report.degradation_details.len(), 1);
assert_eq!(report.degradation_details[0].input, RELATES_TO_CODE);
assert!(!report.degradation_details[0].required);
}
#[test]
fn bridge_edges_are_hypotheses() {
let edge = BridgeEdgeHypothesis::inferred(
"memory-1",
"symbol-1",
RELATES_TO_CODE,
"gobby-memory",
Some(0.72),
);
assert_eq!(edge.label, "inferred hypothesis");
assert_eq!(edge.metadata.provenance, ProjectionProvenance::Inferred);
assert!(edge.metadata.is_hypothesis());
let mut report = empty_report("project-1");
report.bridge_edges.push(edge);
let json = serde_json::to_value(&report).expect("report serializes");
assert_eq!(json["bridge_edges"][0]["label"], "inferred hypothesis");
assert_eq!(
json["bridge_edges"][0]["metadata"]["provenance"],
"INFERRED"
);
}
}