gobby-wiki 0.7.0

Gobby wiki CLI shell
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use gobby_core::degradation::DegradationKind;

use crate::graph::{
    self, MENTIONS_TARGET_REL, SUPPORTS_REL, WIKI_DOC_LABEL, WIKI_LINKS_TO_REL, WIKI_SOURCE_LABEL,
    WIKI_TARGET_LABEL, WikiGraphDocument, WikiGraphFacts, WikiGraphLink, WikiGraphLinkTarget,
    WikiGraphSource, graph_write_statements,
};
use crate::search::SearchScope;

use super::boost::partial_graph_degradation;
use super::code_edges::{
    code_call_edges_query, code_doc_source_path, code_edge_query_params, code_import_edges_query,
    record_code_edge_truncation, remaining_code_edge_limit, truncate_to_limit,
    truncation_component,
};
use super::query::scope_params;
use super::wiki_facts::{resolve_graph_target, slug_target_map};
use super::{
    CODE_CALL_EDGE_TRUNCATION_COMPONENT, CODE_IMPORT_EDGE_TRUNCATION_COMPONENT,
    CODE_TOTAL_EDGE_TRUNCATION_COMPONENT, FALKORDB_GRAPH_NAME, MAX_TOTAL_CODE_EDGES,
};

#[test]
fn falkordb_graph_name_is_wiki_owned() {
    assert_eq!(FALKORDB_GRAPH_NAME, "gobby_wiki");
    assert_ne!(FALKORDB_GRAPH_NAME, "gobby_code");
}

#[test]
fn graph_resolution_keeps_unresolved_targets_and_skips_external() {
    let scope = SearchScope::topic("rust");
    let documents = vec![WikiGraphDocument {
        scope: scope.clone(),
        path: PathBuf::from("knowledge/topics/rust.md"),
        title: Some("Rust Async".to_string()),
    }];
    let document_paths = documents
        .iter()
        .map(|document| document.path.clone())
        .collect::<BTreeSet<_>>();
    let slug_targets = slug_target_map(&documents);
    assert_eq!(
        resolve_graph_target(
            "Rust Async",
            Path::new("knowledge/topics/source.md"),
            &document_paths,
            &slug_targets
        ),
        Some(graph::WikiGraphLinkTarget::Resolved(PathBuf::from(
            "knowledge/topics/rust.md"
        )))
    );
    assert_eq!(
        resolve_graph_target(
            "Missing Page",
            Path::new("knowledge/topics/source.md"),
            &document_paths,
            &slug_targets
        ),
        Some(graph::WikiGraphLinkTarget::Unresolved(
            "Missing Page".to_string()
        ))
    );
    assert!(
        resolve_graph_target(
            "https://example.test",
            Path::new("knowledge/topics/source.md"),
            &document_paths,
            &slug_targets
        )
        .is_none()
    );
}

#[test]
fn graph_scope_params_are_cypher_string_literals() {
    let params = scope_params(&SearchScope::topic("rust'async")).expect("scoped params");

    assert_eq!(
        params.get("scope_kind").map(String::as_str),
        Some("'topic'")
    );
    assert_eq!(
        params.get("scope_id").map(String::as_str),
        Some("'rust\\'async'")
    );
}

#[test]
fn graph_scope_params_omit_global_scope_filters() {
    assert!(scope_params(&SearchScope::global()).is_none());
}

#[test]
fn ask_unified_graph_code_doc_source_path_maps_to_code_file() {
    assert_eq!(
        code_doc_source_path(Path::new("code/files/crates/gwiki/src/lib.rs.md")),
        Some("crates/gwiki/src/lib.rs".to_string())
    );
    assert_eq!(code_doc_source_path(Path::new("wiki/notes.md")), None);
}

#[test]
fn cypher_string_literal_escapes_like_gcode() {
    assert_eq!(
        cypher_string_literal("a\"b\\c'd\n\r\t\u{0008}\u{000C}\u{001F}"),
        "'a\\\"b\\\\c\\'d\\n\\r\\t\\b\\f\\u001F'"
    );
}

#[test]
fn partial_graph_degradation_reports_capped_components() {
    let degradation =
        partial_graph_degradation(&["documents>10".to_string(), "links>20".to_string()])
            .expect("partial data degradation");

    let DegradationKind::PartialData { component, message } = degradation else {
        panic!("expected partial data degradation");
    };
    assert_eq!(component, "gwiki_graph");
    assert!(message.contains("documents>10"));
    assert!(message.contains("links>20"));
}

#[test]
fn code_edge_query_params_use_sentinel_limit_and_parameterized_queries() {
    let call_query = code_call_edges_query();
    let import_query = code_import_edges_query();

    assert!(call_query.contains("LIMIT $limit"));
    assert!(import_query.contains("LIMIT $limit"));
    assert!(
        call_query.contains(
            "ORDER BY source.file_path, source.name, target.file_path, target.name, r.line"
        )
    );
    assert!(import_query.contains("ORDER BY file.path, module.name"));
    assert!(!call_query.contains("LIMIT 200"));
    assert!(!import_query.contains("LIMIT 200"));

    let params = code_edge_query_params("project-1", "src/lib.rs", 7).expect("params");

    assert_eq!(params.get("limit").map(String::as_str), Some("8"));
}

#[test]
fn truncation_components_name_capped_call_and_import_queries() {
    assert_eq!(
        truncation_component(CODE_CALL_EDGE_TRUNCATION_COMPONENT, 7),
        "code_call_edges>7"
    );
    assert_eq!(
        truncation_component(CODE_IMPORT_EDGE_TRUNCATION_COMPONENT, 9),
        "code_import_edges>9"
    );
}

#[test]
fn code_edge_query_limit_respects_total_remaining_cap() {
    assert_eq!(
        remaining_code_edge_limit(200, MAX_TOTAL_CODE_EDGES),
        Some(200)
    );
    assert_eq!(remaining_code_edge_limit(200, 17), Some(17));
    assert_eq!(remaining_code_edge_limit(200, 0), None);

    let mut components = BTreeSet::new();
    record_code_edge_truncation(
        &mut components,
        CODE_CALL_EDGE_TRUNCATION_COMPONENT,
        200,
        17,
    );
    assert!(components.contains(&truncation_component(
        CODE_TOTAL_EDGE_TRUNCATION_COMPONENT,
        MAX_TOTAL_CODE_EDGES
    )));
}

#[test]
fn truncate_to_limit_handles_sentinel_rows_and_zero_limit() {
    let mut rows = vec![1, 2, 3];
    assert!(truncate_to_limit(&mut rows, 2));
    assert_eq!(rows, vec![1, 2]);

    let mut rows = vec![1];
    assert!(truncate_to_limit(&mut rows, 0));
    assert!(rows.is_empty());

    let mut rows = vec![1, 2];
    assert!(!truncate_to_limit(&mut rows, 2));
    assert_eq!(rows, vec![1, 2]);
}

#[test]
fn graph_write_uses_wiki_labels_and_relationships() {
    let facts = WikiGraphFacts {
        documents: vec![
            WikiGraphDocument {
                scope: SearchScope::topic("rust"),
                path: PathBuf::from("wiki/a.md"),
                title: Some("A".to_string()),
            },
            WikiGraphDocument {
                scope: SearchScope::topic("rust"),
                path: PathBuf::from("wiki/b.md"),
                title: Some("B".to_string()),
            },
        ],
        links: vec![
            WikiGraphLink {
                scope: SearchScope::topic("rust"),
                source_path: PathBuf::from("wiki/a.md"),
                raw_target: "wiki/b.md".to_string(),
                target: WikiGraphLinkTarget::Resolved(PathBuf::from("wiki/b.md")),
            },
            WikiGraphLink {
                scope: SearchScope::topic("rust"),
                source_path: PathBuf::from("wiki/a.md"),
                raw_target: "Missing Page".to_string(),
                target: WikiGraphLinkTarget::Unresolved("Missing Page".to_string()),
            },
        ],
        sources: vec![WikiGraphSource {
            scope: SearchScope::topic("rust"),
            source_path: PathBuf::from("raw/a.md"),
            document_path: PathBuf::from("wiki/a.md"),
        }],
        code_edges: Vec::new(),
    };
    let joined = graph_write_statements(&facts)
        .into_iter()
        .map(|statement| statement.cypher)
        .collect::<Vec<_>>()
        .join("\n");
    for token in [
        WIKI_DOC_LABEL,
        WIKI_SOURCE_LABEL,
        WIKI_TARGET_LABEL,
        WIKI_LINKS_TO_REL,
        MENTIONS_TARGET_REL,
        SUPPORTS_REL,
    ] {
        assert!(joined.contains(token), "{token} missing from {joined}");
    }
    assert!(!joined.contains("CodeSymbol"));
    assert!(!joined.contains("gobby_code"));
}

fn cypher_string_literal(value: &str) -> String {
    format!("'{}'", escape_string_contents(value))
}

fn escape_string_contents(value: &str) -> String {
    let mut escaped = String::with_capacity(value.len());
    for ch in value.chars() {
        match ch {
            '\\' => escaped.push_str(r"\\"),
            '\'' => escaped.push_str(r"\'"),
            '"' => escaped.push_str("\\\""),
            '\n' => escaped.push_str(r"\n"),
            '\r' => escaped.push_str(r"\r"),
            '\t' => escaped.push_str(r"\t"),
            '\u{0008}' => escaped.push_str(r"\b"),
            '\u{000C}' => escaped.push_str(r"\f"),
            ch if ch.is_control() => escaped.push_str(&format!(r"\u{:04X}", ch as u32)),
            ch => escaped.push(ch),
        }
    }
    escaped
}

#[test]
fn wiki_graph_edge_cleanup_uses_owned_relationships_and_scope() {
    let scope = SearchScope::topic("rust");
    let statements = super::sync::scope_edge_cleanup_statements(&scope);
    assert_eq!(statements.len(), 3);
    let cyphers = statements
        .iter()
        .map(|statement| statement.cypher.clone())
        .collect::<Vec<_>>();
    for rel in [WIKI_LINKS_TO_REL, MENTIONS_TARGET_REL, SUPPORTS_REL] {
        assert!(
            cyphers.iter().any(|cypher| cypher.contains(rel)),
            "edge cleanup must cover {rel}"
        );
    }
    for cypher in &cyphers {
        assert!(cypher.contains("DELETE edge"));
        assert!(cypher.contains("scope_kind"));
        assert!(cypher.contains("'topic'"));
        assert!(cypher.contains("scope_id"));
        assert!(cypher.contains("'rust'"));
        // Edges are deleted; nodes are preserved (no node-level DETACH DELETE).
        assert!(!cypher.contains("DETACH DELETE"));
    }
}

#[test]
fn wiki_graph_purge_detaches_only_scoped_wiki_nodes() {
    let scope = SearchScope::project("demo");
    let statements = super::sync::scope_purge_statements(&scope);
    assert_eq!(statements.len(), 4);
    let joined = statements
        .iter()
        .map(|statement| statement.cypher.as_str())
        .collect::<Vec<_>>()
        .join("\n");

    for rel in [WIKI_LINKS_TO_REL, MENTIONS_TARGET_REL, SUPPORTS_REL] {
        assert!(
            joined.contains(rel),
            "purge must first clean owned edge type {rel}"
        );
    }
    assert!(joined.contains("MATCH (node"));
    assert!(joined.contains("scope_kind"));
    assert!(joined.contains("'project'"));
    assert!(joined.contains("scope_id"));
    assert!(joined.contains("'demo'"));
    assert!(joined.contains("DETACH DELETE node"));
    assert!(!joined.contains("CodeSymbol"));
    assert!(!joined.contains("gobby_code"));
}

#[test]
fn stale_doc_delete_detaches_scoped_wikidoc_by_path() {
    let scope = SearchScope::project("demo");
    let statement = super::sync::stale_doc_delete_statement(&scope, "knowledge/sources/src-abc.md");
    let cypher = statement.cypher;
    assert!(cypher.contains(WIKI_DOC_LABEL));
    assert!(cypher.contains("DETACH DELETE doc"));
    assert!(cypher.contains("knowledge/sources/src-abc.md"));
    assert!(cypher.contains("scope_kind"));
    assert!(cypher.contains("'project'"));
    assert!(cypher.contains("'demo'"));
}

#[test]
fn orphan_cleanup_targets_unsupported_sources_and_unmentioned_targets() {
    let scope = SearchScope::topic("rust");
    let statements = super::sync::orphan_cleanup_statements(&scope);
    assert_eq!(statements.len(), 2);
    let source_cypher = &statements[0].cypher;
    assert!(source_cypher.contains(WIKI_SOURCE_LABEL));
    assert!(source_cypher.contains(SUPPORTS_REL));
    assert!(source_cypher.contains("WHERE NOT (source)-["));
    assert!(source_cypher.contains("DELETE source"));
    let target_cypher = &statements[1].cypher;
    assert!(target_cypher.contains(WIKI_TARGET_LABEL));
    assert!(target_cypher.contains(MENTIONS_TARGET_REL));
    assert!(target_cypher.contains("WHERE NOT ()-["));
    assert!(target_cypher.contains("DELETE target"));
}

#[test]
fn scoped_projection_guard_rejects_global_scope() {
    assert!(super::sync::require_scoped(&SearchScope::global()).is_err());
    assert!(super::sync::require_scoped(&SearchScope::project("demo")).is_ok());
    assert!(super::sync::require_scoped(&SearchScope::topic("rust")).is_ok());
}