ochna 0.3.2

A structural code graph indexing and analysis CLI using Tree-sitter and SQLite
use serde::{Deserialize, Serialize};

mod edges;
mod files;
mod nodes;
mod raw_calls;
mod refs;
mod schema;

pub use edges::{
    delete_edges_for_source_id, delete_reverse_framework_edges_for_target_id, find_edges_between,
    upsert_edge,
};
pub use files::{
    get_file_metadata, get_project_metadata, upsert_file_metadata, upsert_project_metadata,
};
pub use nodes::{
    delete_file_data, find_callees, find_callers, get_node_ids_and_names_for_file, query_nodes,
    search_nodes_fts, upsert_node,
};
pub use raw_calls::{
    get_all_raw_calls, get_raw_call_source_ids_by_callee_simple, get_raw_calls_for_source_id,
    insert_raw_call,
};
pub use refs::{
    delete_unresolved_refs_for_source_id, get_unresolved_source_ids_by_specifier_simple,
    insert_unresolved_ref, unresolved_reason,
};
pub use schema::{
    create_node_fts_triggers, drop_node_fts_triggers, init_schema, rebuild_node_fts, SCHEMA_VERSION,
};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Node {
    pub id: String,
    pub name: String,
    pub kind: String,
    pub qualified_name: Option<String>,
    pub file_path: String,
    pub start_line: i64,
    pub end_line: i64,
    pub start_column: i64,
    pub end_column: i64,
    pub signature: Option<String>,
    pub doc_comment: Option<String>,
    /// Whether this symbol lives in test code (classified by file path at index
    /// time). Defaults false; populated by `map_row_to_node` from the column.
    #[serde(default)]
    pub is_test: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resolution_kind: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub confidence: Option<i64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Edge {
    pub source_id: String,
    pub target_id: String,
    pub kind: String,
    #[serde(default)]
    pub resolution_kind: i64,
}

#[derive(Debug, Clone, PartialEq)]
pub struct EdgeRecord {
    pub kind: String,
    pub resolution_kind: String,
    pub confidence: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileMetadata {
    pub file_path: String,
    pub content_hash: String,
    pub language: Option<String>,
    pub size_bytes: Option<i64>,
    pub last_modified: Option<i64>,
    pub is_test: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectMetadata {
    pub key: String,
    pub value: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UnresolvedRef {
    pub id: Option<i64>,
    pub source_id: String,
    pub specifier: String,
    pub kind: String,
    pub line: i64,
    pub column: i64,
}

#[derive(Debug, Clone, PartialEq)]
pub struct RawCall {
    pub caller_id: String,
    pub callee_name: String,
    pub callee_simple: String,
    pub callee_scope: Option<String>,
    pub call_kind: Option<String>,
    pub receiver_expr: Option<String>,
    pub receiver_type: Option<String>,
    pub package_or_namespace: Option<String>,
    pub import_hint: Option<String>,
    /// Stable graph relationship token. Ordinary AST invocations use `calls`.
    pub relationship_kind: String,
    /// Framework relationships whose natural reading is target <- source store
    /// their discovered endpoint as the callee and reverse it at resolution.
    pub reverse_edge: bool,
    /// A parser-derived qualified target identity for framework declarations.
    pub target_qualified_hint: Option<String>,
    /// Parser-supplied framework evidence tier; ordinary calls use the resolver.
    pub resolution_hint: Option<i64>,
    pub line: i64,
    pub column: i64,
}

impl RawCall {
    pub fn new(caller_id: String, callee_name: String, line: i64, column: i64) -> Self {
        let (callee_scope, callee_simple) = split_callee_name(&callee_name);
        Self {
            caller_id,
            callee_name,
            callee_simple,
            callee_scope,
            call_kind: None,
            receiver_expr: None,
            receiver_type: None,
            package_or_namespace: None,
            import_hint: None,
            relationship_kind: "calls".to_string(),
            reverse_edge: false,
            target_qualified_hint: None,
            resolution_hint: None,
            line,
            column,
        }
    }
}

pub(crate) fn split_callee_name(callee_name: &str) -> (Option<String>, String) {
    if let Some((scope, simple)) = callee_name.rsplit_once("::") {
        (Some(scope.to_string()), simple.to_string())
    } else {
        (None, callee_name.to_string())
    }
}

pub fn confidence_for_kind(resolution_kind: i64) -> i64 {
    match resolution_kind {
        0 => 30,  // name_only
        1 => 60,  // same_file
        2 => 80,  // namespace
        3 => 80,  // package
        4 => 90,  // receiver_type
        5 => 100, // exact
        6 => 85,  // framework_annotation
        7 => 70,  // framework_convention
        8 => 80,  // framework_declaration
        _ => 30,
    }
}

pub fn label_for_kind(resolution_kind: i64) -> &'static str {
    match resolution_kind {
        0 => "name_only",
        1 => "same_file",
        2 => "namespace",
        3 => "package",
        4 => "receiver_type",
        5 => "exact",
        6 => "framework_annotation",
        7 => "framework_convention",
        8 => "framework_declaration",
        _ => "unknown",
    }
}

/// Helper mapping database Row back to a Node structure
pub(crate) fn map_row_to_node(row: &rusqlite::Row) -> rusqlite::Result<Node> {
    Ok(Node {
        id: row.get(0)?,
        name: row.get(1)?,
        kind: row.get(2)?,
        qualified_name: row.get(3)?,
        file_path: row.get(4)?,
        start_line: row.get(5)?,
        end_line: row.get(6)?,
        start_column: row.get(7)?,
        end_column: row.get(8)?,
        signature: row.get(9)?,
        doc_comment: row.get(10)?,
        is_test: row.get(11)?,
        resolution_kind: None,
        confidence: None,
    })
}

pub(crate) fn map_row_to_raw_call(row: &rusqlite::Row) -> rusqlite::Result<RawCall> {
    let caller_id: String = row.get(0)?;
    let callee_name: String = row.get(1)?;
    let line: i64 = row.get(13)?;
    let column: i64 = row.get(14)?;
    let mut call = RawCall::new(caller_id, callee_name, line, column);
    if let Some(callee_simple) = row.get::<_, Option<String>>(2)? {
        call.callee_simple = callee_simple;
    }
    call.callee_scope = row.get(3)?;
    call.call_kind = row.get(4)?;
    call.receiver_expr = row.get(5)?;
    call.receiver_type = row.get(6)?;
    call.package_or_namespace = row.get(7)?;
    call.import_hint = row.get(8)?;
    call.relationship_kind = row.get(9)?;
    call.reverse_edge = row.get::<_, i64>(10)? != 0;
    call.target_qualified_hint = row.get(11)?;
    call.resolution_hint = row.get(12)?;
    Ok(call)
}

#[cfg(test)]
mod tests {
    use super::*;
    use rusqlite::Connection;

    #[test]
    fn test_db_workflow() {
        let conn = Connection::open_in_memory().unwrap();
        init_schema(&conn).unwrap();

        // 1. Insert file metadata
        let file_meta = FileMetadata {
            file_path: "src/main.rs".to_string(),
            content_hash: "abcdef123456".to_string(),
            language: Some("rust".to_string()),
            size_bytes: Some(1024),
            last_modified: Some(1670000000),
            is_test: false,
        };
        upsert_file_metadata(&conn, &file_meta).unwrap();

        // Verify file metadata insertion
        let retrieved_file = get_file_metadata(&conn, "src/main.rs").unwrap().unwrap();
        assert_eq!(retrieved_file, file_meta);

        // 2. Insert nodes
        let node_main = Node {
            id: "src/main.rs::main".to_string(),
            name: "main".to_string(),
            kind: "function".to_string(),
            qualified_name: Some("main".to_string()),
            file_path: "src/main.rs".to_string(),
            start_line: 11,
            end_line: 15,
            start_column: 9,
            end_column: 1,
            signature: Some("fn main()".to_string()),
            doc_comment: Some("Main entrypoint".to_string()),
            is_test: false,
            resolution_kind: None,
            confidence: None,
        };

        let node_helper = Node {
            id: "src/main.rs::helper".to_string(),
            name: "helper".to_string(),
            kind: "function".to_string(),
            qualified_name: Some("helper".to_string()),
            file_path: "src/main.rs".to_string(),
            start_line: 20,
            end_line: 25,
            start_column: 0,
            end_column: 0,
            signature: Some("fn helper()".to_string()),
            doc_comment: Some("Helper function that does magic".to_string()),
            is_test: false,
            resolution_kind: None,
            confidence: None,
        };

        upsert_node(&conn, &node_main).unwrap();
        upsert_node(&conn, &node_helper).unwrap();

        // Verify querying nodes
        let nodes_by_name = query_nodes(&conn, Some("main"), None, None).unwrap();
        assert_eq!(nodes_by_name.len(), 1);
        assert_eq!(nodes_by_name[0], node_main);

        let nodes_by_path = query_nodes(&conn, None, None, Some("src/main.rs")).unwrap();
        assert_eq!(nodes_by_path.len(), 2);

        // 3. Insert edges
        let edge_call = Edge {
            source_id: "src/main.rs::main".to_string(),
            target_id: "src/main.rs::helper".to_string(),
            kind: "calls".to_string(),
            resolution_kind: 5,
        };
        upsert_edge(&conn, &edge_call).unwrap();

        // Verify callers/callees
        let callers = find_callers(&conn, "src/main.rs::helper", Some("calls")).unwrap();
        assert_eq!(callers.len(), 1);
        assert_eq!(callers[0].id, "src/main.rs::main");

        let callees = find_callees(&conn, "src/main.rs::main", Some("calls")).unwrap();
        assert_eq!(callees.len(), 1);
        assert_eq!(callees[0].id, "src/main.rs::helper");

        // 4. Test FTS (full-text search)
        let fts_results = search_nodes_fts(&conn, "magic").unwrap();
        assert_eq!(fts_results.len(), 1);
        assert_eq!(fts_results[0].id, "src/main.rs::helper");

        let fts_results_doc = search_nodes_fts(&conn, "entrypoint").unwrap();
        assert_eq!(fts_results_doc.len(), 1);
        assert_eq!(fts_results_doc[0].id, "src/main.rs::main");

        // 5. Test project metadata
        upsert_project_metadata(&conn, "version", Some("0.1.0")).unwrap();
        let version_val = get_project_metadata(&conn, "version").unwrap();
        assert_eq!(version_val, Some("0.1.0".to_string()));

        // 6. Test deletion
        delete_file_data(&conn, "src/main.rs").unwrap();

        // Verify nodes are deleted
        let nodes_after_delete = query_nodes(&conn, None, None, Some("src/main.rs")).unwrap();
        assert!(nodes_after_delete.is_empty());

        // Verify edges are cascade-deleted
        let callers_after_delete =
            find_callers(&conn, "src/main.rs::helper", Some("calls")).unwrap();
        assert!(callers_after_delete.is_empty());

        // Verify file metadata is deleted
        let file_meta_after_delete = get_file_metadata(&conn, "src/main.rs").unwrap();
        assert!(file_meta_after_delete.is_none());

        // Verify FTS is updated after delete
        let fts_results_after_delete = search_nodes_fts(&conn, "magic").unwrap();
        assert!(fts_results_after_delete.is_empty());
    }

    #[test]
    fn search_nodes_fts_treats_special_characters_as_literal_text() {
        let conn = Connection::open_in_memory().unwrap();
        init_schema(&conn).unwrap();

        let hyphenated = Node {
            id: "src/lib.rs::my-helper".to_string(),
            name: "my-helper".to_string(),
            kind: "function".to_string(),
            qualified_name: Some("my-helper".to_string()),
            file_path: "src/lib.rs".to_string(),
            start_line: 1,
            end_line: 1,
            start_column: 0,
            end_column: 0,
            signature: None,
            doc_comment: None,
            is_test: false,
            resolution_kind: None,
            confidence: None,
        };
        let decoy = Node {
            id: "src/lib.rs::my".to_string(),
            name: "my".to_string(),
            qualified_name: Some("my".to_string()),
            ..hyphenated.clone()
        };
        upsert_node(&conn, &hyphenated).unwrap();
        upsert_node(&conn, &decoy).unwrap();

        // A raw, unescaped FTS5 query would read the leading `-` as the NOT
        // operator (`my AND NOT helper`), excluding the very row that
        // contains both tokens. Quoted per-term, it must match instead.
        let results = search_nodes_fts(&conn, "my-helper").unwrap();
        let ids: Vec<_> = results.iter().map(|n| n.id.as_str()).collect();
        assert!(
            ids.contains(&"src/lib.rs::my-helper"),
            "hyphenated query should match the row containing both tokens, got {ids:?}"
        );

        // Other FTS5 syntax characters must also be treated as literal text,
        // not query operators (`*` prefix, `:` column filter, quotes, parens).
        for query in ["\"quoted\"", "name:my", "(my)", "my*"] {
            search_nodes_fts(&conn, query)
                .unwrap_or_else(|e| panic!("query {query:?} should not error: {e}"));
        }
    }
}