Skip to main content

gobby_code/graph/
report.rs

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        &degradation_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: &degradation_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, &degree, top_n, |node| node.node_type == "file"),
568        high_degree_symbols: top_hotspots(nodes, &degree, top_n, |node| {
569            is_symbol_node(&node.node_type)
570        }),
571        high_degree_modules: top_hotspots(nodes, &degree, 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}