gobby-wiki 0.6.5

Gobby wiki CLI shell
use std::collections::BTreeSet;

use super::{
    GraphExport, GraphExportEdge, GraphExportEdges, GraphExportOptions, WikiGraphFacts,
    WikiGraphLinkTarget, analytics, citation_node, code_endpoint_id, document_id, document_node,
    mermaid_label, mermaid_node_id, source_node, source_node_id, unresolved_target_id,
    unresolved_target_node,
};
use crate::graph::WikiGraphDocument;

impl WikiGraphFacts {
    pub fn export_graph(
        &self,
        options: GraphExportOptions,
    ) -> Result<GraphExport, analytics::GraphAnalyticsError> {
        let mut nodes = Vec::new();
        let mut node_ids = BTreeSet::new();
        let mut edges = GraphExportEdges::default();

        for document in &self.documents {
            let node = document_node(document);
            if node_ids.insert(node.id.clone()) {
                nodes.push(node);
            }
        }

        for source in &self.sources {
            let source_node = source_node(source);
            if node_ids.insert(source_node.id.clone()) {
                nodes.push(source_node);
            }

            let citation_node = citation_node(source);
            let citation_node_id = citation_node.id.clone();
            if node_ids.insert(citation_node_id.clone()) {
                nodes.push(citation_node);
            }

            edges.trust.push(GraphExportEdge {
                source: source_node_id(&source.scope, &source.source_path),
                target: document_id(&source.scope, &source.document_path),
                kind: "supports",
                raw_target: None,
            });
            edges.audit.push(GraphExportEdge {
                source: citation_node_id,
                target: source_node_id(&source.scope, &source.source_path),
                kind: "cites",
                raw_target: None,
            });
        }

        for link in &self.links {
            let target = match &link.target {
                WikiGraphLinkTarget::Resolved(path) => {
                    let node = document_node(&WikiGraphDocument {
                        scope: link.scope.clone(),
                        path: path.clone(),
                        title: None,
                    });
                    let node_id = node.id.clone();
                    if node_ids.insert(node_id.clone()) {
                        nodes.push(node);
                    }
                    document_id(&link.scope, path)
                }
                WikiGraphLinkTarget::Unresolved(target) => {
                    let node = unresolved_target_node(&link.scope, target);
                    let node_id = node.id.clone();
                    if node_ids.insert(node_id.clone()) {
                        nodes.push(node);
                    }
                    unresolved_target_id(&link.scope, target)
                }
            };
            edges.links.push(GraphExportEdge {
                source: document_id(&link.scope, &link.source_path),
                target,
                kind: "links",
                raw_target: Some(link.raw_target.clone()),
            });
        }

        for edge in &self.code_edges {
            let (kind, output_edges) = match edge.kind.as_str() {
                "imports" => ("imports", &mut edges.imports),
                "callers" => ("callers", &mut edges.callers),
                "calls" => ("calls", &mut edges.calls),
                other => {
                    log::warn!("unknown gwiki graph code edge kind `{other}`; exporting as calls");
                    ("calls", &mut edges.calls)
                }
            };
            output_edges.push(GraphExportEdge {
                source: code_endpoint_id(&edge.scope, &edge.source),
                target: code_endpoint_id(&edge.scope, &edge.target),
                kind,
                raw_target: Some(edge.provenance.clone()),
            });
        }

        let degraded = !options.degraded_sources.is_empty();
        Ok(GraphExport {
            command: "graph",
            degraded,
            degraded_sources: options.degraded_sources,
            analytics: analytics::analyze_facts(self)?,
            nodes,
            edges,
        })
    }
}

pub fn render_graph_report(export: &GraphExport) -> String {
    let mut report = String::from("# GWiki Graph Report\n\n");
    report.push_str(&format!("- Nodes: {}\n", export.nodes.len()));
    report.push_str(&format!(
        "- Edges: {}\n\n",
        export.edges.links.len()
            + export.edges.imports.len()
            + export.edges.calls.len()
            + export.edges.callers.len()
            + export.edges.trust.len()
            + export.edges.audit.len()
    ));

    report.push_str("## Degraded sources\n\n");
    if export.degraded_sources.is_empty() {
        report.push_str("- none\n\n");
    } else {
        for source in &export.degraded_sources {
            report.push_str(&format!("- {source}\n"));
        }
        report.push('\n');
    }

    report.push_str("## Analytics\n\n");
    report.push_str(&format!(
        "- Communities: {}\n",
        export.analytics.communities.len()
    ));
    if let Some(top) = export.analytics.centrality.first() {
        report.push_str(&format!(
            "- Top central node: {} (degree {})\n",
            top.node.id, top.degree
        ));
    } else {
        report.push_str("- Top central node: none\n");
    }
    report.push_str(&format!("- Bridges: {}\n", export.analytics.bridges.len()));
    report.push_str(&format!(
        "- Hotspots: {}\n\n",
        export.analytics.hotspots.len()
    ));

    report.push_str("## Overview\n\n```mermaid\ngraph LR\n");
    for node in &export.nodes {
        report.push_str(&format!(
            "    {}[\"{}\"]\n",
            mermaid_node_id(&node.id),
            mermaid_label(node)
        ));
    }
    for edge in export
        .edges
        .links
        .iter()
        .chain(export.edges.imports.iter())
        .chain(export.edges.calls.iter())
        .chain(export.edges.callers.iter())
        .chain(export.edges.trust.iter())
        .chain(export.edges.audit.iter())
    {
        report.push_str(&format!(
            "    {} --> {}\n",
            mermaid_node_id(&edge.source),
            mermaid_node_id(&edge.target)
        ));
    }
    report.push_str("```\n\n");

    report.push_str("## Edge classes\n\n");
    report.push_str(&format!("- links: {}\n", export.edges.links.len()));
    report.push_str(&format!("- imports: {}\n", export.edges.imports.len()));
    report.push_str(&format!("- calls: {}\n", export.edges.calls.len()));
    report.push_str(&format!("- callers: {}\n", export.edges.callers.len()));
    report.push_str(&format!("- trust: {}\n", export.edges.trust.len()));
    report.push_str(&format!("- audit: {}\n", export.edges.audit.len()));
    report
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use crate::search::SearchScope;

    use super::*;
    use crate::graph::{
        WikiGraphCodeEdge, WikiGraphDocument, WikiGraphLink, WikiGraphLinkTarget, WikiGraphSource,
    };

    #[test]
    fn export_graph_scopes_ids_and_emits_unresolved_target_nodes() {
        let project = SearchScope::project("project-1");
        let topic = SearchScope::topic("project-1");
        let facts = WikiGraphFacts {
            documents: vec![
                WikiGraphDocument {
                    scope: project.clone(),
                    path: "knowledge/topics/shared.md".into(),
                    title: Some("Shared".to_string()),
                },
                WikiGraphDocument {
                    scope: topic.clone(),
                    path: "knowledge/topics/shared.md".into(),
                    title: Some("Shared".to_string()),
                },
            ],
            links: vec![
                WikiGraphLink {
                    scope: project.clone(),
                    source_path: "knowledge/topics/shared.md".into(),
                    raw_target: "Missing".to_string(),
                    target: WikiGraphLinkTarget::Unresolved("Missing".to_string()),
                },
                WikiGraphLink {
                    scope: topic.clone(),
                    source_path: "knowledge/topics/shared.md".into(),
                    raw_target: "Missing".to_string(),
                    target: WikiGraphLinkTarget::Unresolved("Missing".to_string()),
                },
            ],
            sources: vec![WikiGraphSource {
                scope: project.clone(),
                source_path: "raw/source.md".into(),
                document_path: "knowledge/topics/shared.md".into(),
            }],
            code_edges: vec![
                WikiGraphCodeEdge {
                    scope: project.clone(),
                    document_path: PathBuf::from("knowledge/topics/shared.md"),
                    source: "src/lib.rs".to_string(),
                    target: "crate::main".to_string(),
                    kind: "imports".to_string(),
                    direction: "outgoing".to_string(),
                    line: None,
                    provenance: "test".to_string(),
                },
                WikiGraphCodeEdge {
                    scope: project.clone(),
                    document_path: PathBuf::from("knowledge/topics/shared.md"),
                    source: "src/lib.rs:run".to_string(),
                    target: "src/main.rs:main".to_string(),
                    kind: "calls".to_string(),
                    direction: "outgoing".to_string(),
                    line: Some(1),
                    provenance: "test".to_string(),
                },
                WikiGraphCodeEdge {
                    scope: SearchScope::project("project-1"),
                    document_path: PathBuf::from("knowledge/topics/shared.md"),
                    source: "src/main.rs:main".to_string(),
                    target: "src/lib.rs:run".to_string(),
                    kind: "callers".to_string(),
                    direction: "incoming".to_string(),
                    line: Some(2),
                    provenance: "test".to_string(),
                },
            ],
        };

        let export = facts
            .export_graph(GraphExportOptions::available())
            .expect("graph export");
        let node_ids = export
            .nodes
            .iter()
            .map(|node| node.id.as_str())
            .collect::<BTreeSet<_>>();
        let project_document = document_id(&project, &PathBuf::from("knowledge/topics/shared.md"));
        let topic_document = document_id(&topic, &PathBuf::from("knowledge/topics/shared.md"));
        let project_unresolved = unresolved_target_id(&project, "Missing");
        let topic_unresolved = unresolved_target_id(&topic, "Missing");
        let import_source = code_endpoint_id(&project, "src/lib.rs");
        let call_source = code_endpoint_id(&project, "src/lib.rs:run");
        let call_target = code_endpoint_id(&project, "src/main.rs:main");

        assert!(node_ids.contains(project_document.as_str()));
        assert!(node_ids.contains(topic_document.as_str()));
        assert!(node_ids.contains(project_unresolved.as_str()));
        assert!(node_ids.contains(topic_unresolved.as_str()));
        assert_eq!(export.edges.links[0].source, project_document);
        assert_eq!(export.edges.links[1].source, topic_document);
        assert_eq!(export.edges.imports[0].source, import_source);
        assert_eq!(export.edges.calls[0].source, call_source);
        assert_eq!(export.edges.calls[0].target, call_target);
        assert_eq!(export.edges.callers[0].source, call_target);
        assert_eq!(export.edges.callers[0].target, call_source);
        let report = render_graph_report(&export);
        assert!(report.contains(&format!(
            "{} --> {}",
            mermaid_node_id(&export.edges.imports[0].source),
            mermaid_node_id(&export.edges.imports[0].target)
        )));
        assert!(report.contains(&format!(
            "{} --> {}",
            mermaid_node_id(&export.edges.calls[0].source),
            mermaid_node_id(&export.edges.calls[0].target)
        )));
        assert!(report.contains(&format!(
            "{} --> {}",
            mermaid_node_id(&export.edges.callers[0].source),
            mermaid_node_id(&export.edges.callers[0].target)
        )));
        assert!(report.contains("- callers: 1"));
    }

    #[test]
    fn export_graph_adds_placeholder_for_missing_resolved_target() {
        let scope = SearchScope::project("project-1");
        let facts = WikiGraphFacts {
            documents: vec![WikiGraphDocument {
                scope: scope.clone(),
                path: "knowledge/topics/a.md".into(),
                title: Some("A".to_string()),
            }],
            links: vec![WikiGraphLink {
                scope: scope.clone(),
                source_path: "knowledge/topics/a.md".into(),
                raw_target: "B".to_string(),
                target: WikiGraphLinkTarget::Resolved("knowledge/topics/b.md".into()),
            }],
            sources: Vec::new(),
            code_edges: Vec::new(),
        };

        let export = facts
            .export_graph(GraphExportOptions::available())
            .expect("graph export");
        let target_id = document_id(&scope, &PathBuf::from("knowledge/topics/b.md"));

        assert!(export.nodes.iter().any(|node| {
            node.id == target_id
                && node.kind == "wiki_page"
                && node.path == "knowledge/topics/b.md"
                && node.title.is_none()
        }));
    }
}