grapha 0.3.0

Blazingly fast code intelligence CLI and MCP server for Swift and Rust
Documentation
use serde::Serialize;

use grapha_core::graph::{Graph, NodeRole};

use super::{SymbolRef, file_matches_path_or_suffix};

#[derive(Debug, Clone, Default)]
pub struct EntriesQueryOptions {
    pub module: Option<String>,
    pub file: Option<String>,
    pub limit: Option<usize>,
}

#[derive(Debug, Serialize)]
pub struct EntriesResult {
    pub entries: Vec<SymbolRef>,
    pub shown: usize,
    pub total: usize,
}

fn sort_entries(entries: &mut [SymbolRef]) {
    entries.sort_by(|left, right| {
        left.module
            .as_deref()
            .unwrap_or("")
            .cmp(right.module.as_deref().unwrap_or(""))
            .then_with(|| left.file.cmp(&right.file))
            .then_with(|| left.name.cmp(&right.name))
            .then_with(|| left.id.cmp(&right.id))
    });
}

pub fn query_entries_with_options(graph: &Graph, options: &EntriesQueryOptions) -> EntriesResult {
    let mut entries: Vec<SymbolRef> = graph
        .nodes
        .iter()
        .filter(|n| n.role == Some(NodeRole::EntryPoint))
        .filter(|node| {
            options
                .module
                .as_deref()
                .is_none_or(|module| node.module.as_deref() == Some(module))
        })
        .filter(|node| {
            options
                .file
                .as_deref()
                .is_none_or(|file_query| file_matches_path_or_suffix(&node.file, file_query))
        })
        .map(SymbolRef::from_node)
        .collect();

    sort_entries(&mut entries);

    let total = entries.len();
    let shown = options.limit.map(|limit| limit.min(total)).unwrap_or(total);
    entries.truncate(shown);

    EntriesResult {
        entries,
        shown,
        total,
    }
}

pub fn query_entries(graph: &Graph) -> EntriesResult {
    query_entries_with_options(graph, &EntriesQueryOptions::default())
}

#[cfg(test)]
mod tests {
    use super::*;
    use grapha_core::graph::*;
    use std::collections::HashMap;
    use std::path::PathBuf;

    fn make_node(id: &str, role: Option<NodeRole>) -> Node {
        Node {
            id: id.into(),
            kind: NodeKind::Function,
            name: id.into(),
            file: PathBuf::from("test.rs"),
            span: Span {
                start: [0, 0],
                end: [1, 0],
            },
            visibility: Visibility::Public,
            metadata: HashMap::new(),
            role,
            signature: None,
            doc_comment: None,
            module: None,
            snippet: None,
        }
    }

    fn entry_node(id: &str, name: &str, file: &str, module: Option<&str>) -> Node {
        Node {
            id: id.into(),
            kind: NodeKind::Function,
            name: name.into(),
            file: PathBuf::from(file),
            span: Span {
                start: [0, 0],
                end: [1, 0],
            },
            visibility: Visibility::Public,
            metadata: HashMap::new(),
            role: Some(NodeRole::EntryPoint),
            signature: None,
            doc_comment: None,
            module: module.map(str::to_string),
            snippet: None,
        }
    }

    #[test]
    fn lists_entry_points() {
        let graph = Graph {
            version: "0.1.0".to_string(),
            nodes: vec![
                make_node("entry1", Some(NodeRole::EntryPoint)),
                make_node("entry2", Some(NodeRole::EntryPoint)),
                make_node("internal", Some(NodeRole::Internal)),
            ],
            edges: vec![],
        };

        let result = query_entries(&graph);
        assert_eq!(result.total, 2);
        let names: Vec<&str> = result.entries.iter().map(|e| e.name.as_str()).collect();
        assert!(names.contains(&"entry1"));
        assert!(names.contains(&"entry2"));
        assert!(!names.contains(&"internal"));
    }

    #[test]
    fn returns_empty_when_no_entries() {
        let graph = Graph {
            version: "0.1.0".to_string(),
            nodes: vec![
                make_node("a", None),
                make_node("b", Some(NodeRole::Internal)),
            ],
            edges: vec![],
        };

        let result = query_entries(&graph);
        assert_eq!(result.total, 0);
        assert!(result.entries.is_empty());
    }

    #[test]
    fn filters_entries_by_module_and_file_and_limit() {
        let graph = Graph {
            version: "0.1.0".to_string(),
            nodes: vec![
                entry_node(
                    "room_body",
                    "body",
                    "Modules/Room/Sources/Room/View/RoomPage.swift",
                    Some("Room"),
                ),
                entry_node(
                    "room_share",
                    "onShare",
                    "Modules/Room/Sources/Room/View/RoomPage.swift",
                    Some("Room"),
                ),
                entry_node(
                    "chat_body",
                    "body",
                    "Modules/Chat/Sources/Chat/View/ChatPage.swift",
                    Some("Chat"),
                ),
            ],
            edges: vec![],
        };

        let result = query_entries_with_options(
            &graph,
            &EntriesQueryOptions {
                module: Some("Room".to_string()),
                file: Some("RoomPage.swift".to_string()),
                limit: Some(1),
            },
        );
        let actual: Vec<(&str, &str, &str, Option<&str>)> = result
            .entries
            .iter()
            .map(|entry| {
                (
                    entry.id.as_str(),
                    entry.name.as_str(),
                    entry.file.rsplit('/').next().unwrap_or(entry.file.as_str()),
                    entry.module.as_deref(),
                )
            })
            .collect();
        let expected = vec![("room_body", "body", "RoomPage.swift", Some("Room"))];

        assert_eq!(actual, expected);
        assert_eq!(result.total, 2);
        assert_eq!(result.shown, 1);
    }

    #[test]
    fn file_filter_does_not_match_partial_fragments() {
        let graph = Graph {
            version: "0.1.0".to_string(),
            nodes: vec![entry_node(
                "room_body",
                "body",
                "Modules/Room/Sources/Room/View/RoomPage.swift",
                Some("Room"),
            )],
            edges: vec![],
        };

        let result = query_entries_with_options(
            &graph,
            &EntriesQueryOptions {
                file: Some("Page".to_string()),
                ..EntriesQueryOptions::default()
            },
        );

        assert_eq!(result.total, 0);
        assert_eq!(result.shown, 0);
        assert!(result.entries.is_empty());
    }
}