Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,984 changes: 1,936 additions & 48 deletions cli/Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ tiktoken-rs = "0.9.1"
tempfile = "3"
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
neo4rs = { version = "0.8", optional = true }
termtree = { version = "0.4", optional = true }

[features]
neo4j = ["ast/neo4j", "ast/openssl", "dep:neo4rs", "dep:termtree"]
95 changes: 95 additions & 0 deletions cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ pub struct CliArgs {
#[arg(long, action = ArgAction::SetTrue)]
pub stats: bool,

/// Use Neo4j graph for enhanced output (requires --features neo4j build)
#[arg(long, action = ArgAction::SetTrue)]
pub neo4j: bool,

/// Input files or directories (comma-separated or multiple args)
#[arg(value_name = "FILE_OR_DIR", num_args = 0..)]
pub files: Vec<String>,
Expand All @@ -59,6 +63,8 @@ pub enum Commands {
Completions(CompletionsArgs),
/// Explore git changes summaries scoped to specific files or directories
Changes(ChangesArgs),
/// Query and manage the Neo4j knowledge graph (requires --features neo4j build)
Graph(GraphArgs),
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -135,6 +141,91 @@ pub struct DiffArgs {
pub paths: Vec<String>,
}

#[derive(Debug, Args)]
pub struct GraphArgs {
#[command(subcommand)]
pub command: GraphCommand,
}

#[derive(Debug, Subcommand)]
pub enum GraphCommand {
/// Ingest a local repository into Neo4j
Ingest(GraphIngestArgs),
/// Search for nodes by name substring
Search(GraphSearchArgs),
/// Show details for a named node
Node(GraphNodeArgs),
/// Traverse the graph from a node and render an ASCII tree
Map(GraphMapArgs),
/// Print all known node and edge types
Schema,
/// Clear all nodes and edges from the graph
Clear,
/// Show graph size statistics
Stats,
}

#[derive(Debug, Args)]
pub struct GraphIngestArgs {
/// Path to the repository to ingest
#[arg(value_name = "PATH", default_value = ".")]
pub path: String,
}

#[derive(Debug, Args)]
pub struct GraphSearchArgs {
/// Name substring to search for
pub query: String,

/// Limit to these node types, comma-separated (e.g. Function,Endpoint)
#[arg(long, value_delimiter = ',')]
pub node_type: Vec<String>,

/// Maximum number of results to return
#[arg(long, default_value = "20")]
pub limit: usize,
}

#[derive(Debug, Args)]
pub struct GraphNodeArgs {
/// Exact node name to look up
pub name: String,

/// Limit lookup to this node type (e.g. Function, Endpoint)
#[arg(long)]
pub node_type: Option<String>,

/// Filter by file path (exact match) to disambiguate duplicate names
#[arg(long)]
pub file: Option<String>,
}

#[derive(Debug, Args)]
pub struct GraphMapArgs {
/// Name of the node to start traversal from
pub name: String,

/// Node type label (e.g. Function, Endpoint, Class)
#[arg(long)]
pub node_type: Option<String>,

/// Traversal direction: down, up, or both
#[arg(long, default_value = "down")]
pub direction: String,

/// Maximum traversal depth
#[arg(long, default_value_t = 10)]
pub depth: usize,

/// Include test nodes in the traversal
#[arg(long)]
pub tests: bool,

/// Node names to exclude from the tree, comma-separated
#[arg(long, value_delimiter = ',')]
pub trim: Vec<String>,
}

impl CliArgs {
pub fn parse_and_expand() -> Result<Self> {
let mut args = Self::parse();
Expand Down Expand Up @@ -164,6 +255,10 @@ impl CliArgs {
return Ok(args);
}

if let Some(Commands::Graph(_)) = &args.command {
return Ok(args);
}

Ok(args)
}
}
9 changes: 9 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ mod args;
mod changes;
mod completions;
mod git;
#[cfg(feature = "neo4j")]
mod neo4j;
mod output;
mod parse;
mod progress;
Expand Down Expand Up @@ -63,6 +65,13 @@ async fn run() -> Result<()> {
Some(Commands::Changes(args)) => {
changes::run(args, &mut Output::new(), cli.verbose || cli.perf).await
}
#[cfg(feature = "neo4j")]
Some(Commands::Graph(args)) => neo4j::run_graph(args, &mut Output::new()).await,
#[cfg(not(feature = "neo4j"))]
Some(Commands::Graph(_)) => {
eprintln!("error: 'graph' subcommand requires building with --features neo4j");
std::process::exit(1);
}
None => parse::run(&cli, &mut Output::new()).await,
}
}
8 changes: 8 additions & 0 deletions cli/src/neo4j/connection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
use ast::lang::graphs::graph_ops::GraphOps;
use shared::Result;

pub(super) async fn connect_graph_ops() -> Result<GraphOps> {
let mut ops = GraphOps::new();
ops.connect().await?;
Ok(ops)
}
87 changes: 87 additions & 0 deletions cli/src/neo4j/ingest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use std::path::Path;

use ast::lang::graphs::graph::Graph;
use ast::repo::{Repo, Repos};
use ast::Lang;
use lsp::Language;
use shared::Result;

use crate::output::Output;
use crate::progress::CliSpinner;
use crate::utils::common_ancestor;

use super::connection::connect_graph_ops;

pub(super) async fn run_ingest(path: &str, out: &mut Output) -> Result<()> {
let canonical = std::fs::canonicalize(path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| path.to_string());

let mut file_list: Vec<String> = Vec::new();
let root_path = Path::new(&canonical);
if root_path.is_dir() {
for entry in walkdir::WalkDir::new(root_path) {
let entry = entry.map_err(|e| shared::Error::Io(e.into()))?;
if entry.file_type().is_file() {
let p = entry.path().to_string_lossy().to_string();
if Language::from_path(&p).is_some() {
file_list.push(p);
}
}
}
} else if root_path.is_file() {
file_list.push(canonical.clone());
}

if file_list.is_empty() {
out.writeln("No supported source files found.".to_string())?;
return Ok(());
}

let spinner = CliSpinner::new("Building graph from source files...");

let mut repos_vec: Vec<Repo> = Vec::new();
let mut by_lang: std::collections::HashMap<Language, Vec<String>> =
std::collections::HashMap::new();
for f in &file_list {
if let Some(lang) = Language::from_path(f) {
by_lang.entry(lang).or_default().push(f.clone());
}
}
for (language, files) in by_lang {
let lang = Lang::from_language(language);
if let Some(root) = common_ancestor(&files) {
let file_refs: Vec<&str> = files.iter().map(|s| s.as_str()).collect();
let repo = Repo::from_files(&file_refs, root, lang, false, false, false)?;
repos_vec.push(repo);
}
}

let repos = Repos(repos_vec);
spinner.set_message("Parsing source files...");
let btree_graph = repos.build_graphs_local().await?;

let (node_count, edge_count) = {
let (n, e) = btree_graph.get_graph_size();
(n, e)
};

spinner.set_message("Uploading to Neo4j...");
let mut ops = connect_graph_ops().await.map_err(|e| {
spinner.finish_with_message("Failed to connect to Neo4j");
e
})?;

let (final_nodes, final_edges) = ops
.upload_btreemap_to_neo4j(&btree_graph, None)
.await
.map_err(|e| {
spinner.finish_with_message("Upload failed");
e
})?;

spinner.finish_with_message("Ingest complete");
out.writeln(format!("Parsed: {} nodes, {} edges", node_count, edge_count))?;
out.writeln(format!("Neo4j now: {} nodes, {} edges", final_nodes, final_edges))?;
Ok(())
}
Loading
Loading