mod cli;
mod config;
mod db;
mod doc;
mod doc_indexer;
mod graph;
mod indexer;
mod mcp;
mod watcher;
mod web;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "leankg")]
#[command(about = "Lightweight knowledge graph for AI-assisted development")]
pub struct Args {
#[command(subcommand)]
pub command: cli::CLICommand,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
if !matches!(args.command, cli::CLICommand::McpStdio { watch: _ }) {
tracing_subscriber::fmt::init();
}
match args.command {
cli::CLICommand::Init { path } => {
init_project(&path)?;
}
cli::CLICommand::Index {
path,
incremental,
lang,
exclude,
verbose,
} => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
tokio::fs::create_dir_all(&db_path).await?;
let exclude_patterns: Vec<String> = exclude
.as_ref()
.map(|e| e.split(',').map(|s| s.trim().to_string()).collect())
.unwrap_or_default();
if incremental {
incremental_index_codebase(
path.as_deref().unwrap_or("."),
&db_path,
lang.as_deref(),
&exclude_patterns,
verbose,
)
.await?;
} else {
index_codebase(
path.as_deref().unwrap_or("."),
&db_path,
lang.as_deref(),
&exclude_patterns,
verbose,
)
.await?;
}
}
cli::CLICommand::Serve { web_port, .. } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
tokio::fs::create_dir_all(&db_path).await.ok();
println!("Starting LeanKG server...");
println!("Web UI: http://127.0.0.1:{}", web_port);
if let Err(e) = web::start_server(web_port, db_path).await {
eprintln!("Web server error: {}", e);
}
}
cli::CLICommand::McpStdio { watch } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
tokio::fs::create_dir_all(&db_path).await.ok();
let mcp_server = if watch {
mcp::MCPServer::new_with_watch(db_path, project_path.clone())
} else {
mcp::MCPServer::new(db_path)
};
if let Err(e) = mcp_server.serve_stdio().await {
eprintln!("MCP stdio server error: {}", e);
}
}
cli::CLICommand::Impact { file, depth } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
let result = calculate_impact(&file, depth, &db_path)?;
println!("Impact radius for {} (depth={}):", file, depth);
if result.affected_elements.is_empty() {
println!(" No affected elements found");
} else {
for elem in result.affected_elements.iter().take(20) {
println!(" - {}", elem.qualified_name);
}
if result.affected_elements.len() > 20 {
println!(" ... and {} more", result.affected_elements.len() - 20);
}
}
}
cli::CLICommand::Generate { template: _ } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
generate_docs(&db_path)?;
}
cli::CLICommand::Query { query, kind } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
run_query(&query, &kind, &db_path)?;
}
cli::CLICommand::Install => {
install_mcp_config()?;
}
cli::CLICommand::Status => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
show_status(&db_path)?;
}
cli::CLICommand::Watch => {
let project_path = find_project_root()?;
println!("Starting file watcher for {}...", project_path.display());
println!("Watch functionality ready for implementation");
}
cli::CLICommand::Quality { min_lines, lang } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
find_oversized_functions(min_lines, lang.as_deref(), &db_path)?;
}
cli::CLICommand::Export { output: _ } => {
println!("Export functionality ready for implementation");
}
cli::CLICommand::Annotate {
element,
description,
user_story,
feature,
} => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
annotate_element(
&element,
&description,
user_story.as_deref(),
feature.as_deref(),
&db_path,
)?;
}
cli::CLICommand::Link { element, id, kind } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
link_element(&element, &id, &kind, &db_path)?;
}
cli::CLICommand::SearchAnnotations { query } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
search_annotations(&query, &db_path)?;
}
cli::CLICommand::ShowAnnotations { element } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
show_annotations(&element, &db_path)?;
}
cli::CLICommand::Trace {
feature,
user_story,
all,
} => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
show_traceability(&db_path, feature.as_deref(), user_story.as_deref(), all)?;
}
cli::CLICommand::FindByDomain { domain } => {
let project_path = find_project_root()?;
let db_path = project_path.join(".leankg");
find_by_domain(&domain, &db_path)?;
}
}
Ok(())
}
fn find_project_root() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
let current_dir = std::env::current_dir()?;
if current_dir.join(".leankg").exists() || current_dir.join("leankg.yaml").exists() {
return Ok(current_dir);
}
for parent in current_dir.ancestors() {
if parent.join(".leankg").exists() || parent.join("leankg.yaml").exists() {
return Ok(parent.to_path_buf());
}
}
Ok(current_dir)
}
fn init_project(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let config = config::ProjectConfig::default();
let config_yaml = serde_yaml::to_string(&config)?;
std::fs::create_dir_all(path)?;
std::fs::write(std::path::Path::new(path).join("leankg.yaml"), config_yaml)?;
let readme = r#"# Project
This project uses LeanKG for code intelligence.
## Setup
```bash
leankg init
leankg index ./src
```
## Commands
- `leankg index ./src` - Index codebase
- `leankg serve` - Start server
- `leankg impact <file> --depth 3` - Calculate impact radius
"#;
std::fs::write(std::path::Path::new(path).join("README.md"), readme)?;
println!("Initialized LeanKG project at {}", path);
Ok(())
}
async fn index_codebase(
path: &str,
db_path: &std::path::Path,
lang_filter: Option<&str>,
exclude_patterns: &[String],
verbose: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let graph_engine = graph::GraphEngine::new(db);
let mut parser_manager = indexer::ParserManager::new();
parser_manager.init_parsers()?;
println!("Indexing codebase at {}...", path);
let mut files = indexer::find_files_sync(path)?;
if let Some(lang) = lang_filter {
let allowed_langs: Vec<&str> = lang.split(',').map(|s| s.trim()).collect();
files.retain(|f| {
if let Some(ext) = std::path::Path::new(f).extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
let lang_map: std::collections::HashMap<&str, &str> = [
("go", "go"),
("rs", "rust"),
("ts", "typescript"),
("js", "javascript"),
("py", "python"),
]
.iter()
.cloned()
.collect();
if let Some(lang_name) = lang_map.get(ext_str.as_str()) {
return allowed_langs.iter().any(|l| l.to_lowercase() == *lang_name);
}
}
false
});
if verbose {
println!("Language filter applied: {} allowed", allowed_langs.len());
}
}
if !exclude_patterns.is_empty() {
let prev_len = files.len();
files.retain(|f| !exclude_patterns.iter().any(|pat| f.contains(pat)));
if verbose {
println!(
"Excluded {} files (matched --exclude patterns)",
prev_len - files.len()
);
}
}
println!("Found {} files to index", files.len());
let mut indexed = 0;
let mut skipped = 0;
for file_path in &files {
match indexer::index_file_sync(&graph_engine, &mut parser_manager, file_path) {
Ok(count) => {
indexed += 1;
if verbose {
println!(" Indexed {} ({} elements)", file_path, count);
}
}
Err(e) => {
skipped += 1;
if verbose {
println!(" Warning: Failed to index {}: {}", file_path, e);
}
}
}
}
println!("Indexed {} files", indexed);
if skipped > 0 {
println!("Skipped {} files (errors)", skipped);
}
let docs_path = std::path::Path::new("docs");
if docs_path.exists() {
println!("Indexing documentation at docs/...");
match doc_indexer::index_docs_directory(docs_path, &graph_engine) {
Ok(result) => {
println!(
"Indexed {} documents and {} sections",
result.documents.len(),
result.sections.len()
);
if verbose && !result.relationships.is_empty() {
println!(
" Created {} documentation relationships",
result.relationships.len()
);
}
}
Err(e) => {
eprintln!("Warning: Failed to index docs: {}", e);
}
}
}
Ok(())
}
async fn incremental_index_codebase(
path: &str,
db_path: &std::path::Path,
lang_filter: Option<&str>,
exclude_patterns: &[String],
verbose: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let graph_engine = graph::GraphEngine::new(db);
let mut parser_manager = indexer::ParserManager::new();
parser_manager.init_parsers()?;
println!("Performing incremental indexing for {}...", path);
match indexer::incremental_index_sync(&graph_engine, &mut parser_manager, path).await {
Ok(result) => {
if result.changed_files.is_empty() && result.dependent_files.is_empty() {
println!("No changes detected since last index.");
} else {
println!("Changed files: {}", result.changed_files.len());
for f in &result.changed_files {
println!(" Modified: {}", f);
}
println!(
"Dependent files re-indexed: {}",
result.dependent_files.len()
);
for f in &result.dependent_files {
println!(" Dependent: {}", f);
}
println!("Total files processed: {}", result.total_files_processed);
println!("Total elements indexed: {}", result.elements_indexed);
}
}
Err(e) => {
println!(
"Incremental index failed: {}. Falling back to full index.",
e
);
index_codebase(path, db_path, lang_filter, exclude_patterns, verbose).await?;
}
}
Ok(())
}
fn calculate_impact(
file: &str,
depth: u32,
db_path: &std::path::Path,
) -> Result<graph::ImpactResult, Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let graph_engine = graph::GraphEngine::new(db);
let analyzer = graph::ImpactAnalyzer::new(&graph_engine);
let result = analyzer.calculate_impact_radius(file, depth)?;
Ok(result)
}
fn generate_docs(db_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let graph_engine = graph::GraphEngine::new(db);
let generator = doc::DocGenerator::new(graph_engine, std::path::PathBuf::from("./docs"));
let content = generator.generate_agents_md()?;
println!("Generated documentation:\n{}", content);
std::fs::create_dir_all("./docs")?;
std::fs::write("./docs/AGENTS.md", &content)?;
println!("\nSaved to docs/AGENTS.md");
Ok(())
}
fn install_mcp_config() -> Result<(), Box<dyn std::error::Error>> {
let exe_path = std::env::current_exe()
.map_err(|e| format!("Failed to get current exe path: {}", e))?;
let mcp_config = serde_json::json!({
"mcpServers": {
"leankg": {
"command": exe_path.to_string_lossy().as_ref(),
"args": ["mcp-stdio", "--watch"]
}
}
});
std::fs::write(".mcp.json", serde_json::to_string_pretty(&mcp_config)?)?;
println!("Installed MCP config to .mcp.json");
Ok(())
}
fn show_status(db_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
if !db_path.exists() {
println!("LeanKG not initialized. Run 'leankg init' first.");
return Ok(());
}
let db = db::schema::init_db(db_path)?;
let elements = graph::GraphEngine::new(db.clone()).all_elements()?;
let relationships = graph::GraphEngine::new(db.clone())
.all_relationships()?;
let annotations = db::all_business_logic(&db)?;
println!("LeanKG Status:");
println!(" Database: {}", db_path.display());
println!(" Elements: {}", elements.len());
println!(" Relationships: {}", relationships.len());
let files = elements.iter().filter(|e| e.element_type == "file").count();
let functions = elements
.iter()
.filter(|e| e.element_type == "function")
.count();
let classes = elements
.iter()
.filter(|e| e.element_type == "class")
.count();
println!(" Files: {}", files);
println!(" Functions: {}", functions);
println!(" Classes: {}", classes);
println!(" Annotations: {}", annotations.len());
Ok(())
}
fn annotate_element(
element: &str,
description: &str,
user_story: Option<&str>,
feature: Option<&str>,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let existing = db::get_business_logic(&db, element)?;
if existing.is_some() {
db::update_business_logic(&db, element, description, user_story, feature)?;
println!("Updated annotation for '{}'", element);
} else {
db::create_business_logic(&db, element, description, user_story, feature)?;
println!("Created annotation for '{}'", element);
}
println!(" Description: {}", description);
if let Some(story) = user_story {
println!(" User Story: {}", story);
}
if let Some(feat) = feature {
println!(" Feature: {}", feat);
}
Ok(())
}
fn link_element(
element: &str,
id: &str,
kind: &str,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let existing = db::get_business_logic(&db, element)?;
match existing {
Some(bl) => {
if kind == "story" {
let new_desc = if bl.description.starts_with("Linked to") {
bl.description
} else {
format!("{} | Linked to story {}", bl.description, id)
};
db::update_business_logic(
&db,
element,
&new_desc,
Some(id),
bl.feature_id.as_deref(),
)?;
} else {
let new_desc = if bl.description.starts_with("Linked to") {
bl.description
} else {
format!("{} | Linked to feature {}", bl.description, id)
};
db::update_business_logic(
&db,
element,
&new_desc,
bl.user_story_id.as_deref(),
Some(id),
)?;
}
}
None => {
let description = format!("Linked to {} {}", kind, id);
if kind == "story" {
db::create_business_logic(&db, element, &description, Some(id), None)?;
} else {
db::create_business_logic(&db, element, &description, None, Some(id))?;
}
}
}
println!("Linked '{}' to {} {}", element, kind, id);
Ok(())
}
fn search_annotations(
query: &str,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let results = db::search_business_logic(&db, query)?;
if results.is_empty() {
println!("No annotations found matching '{}'", query);
} else {
println!("Found {} annotation(s):", results.len());
for bl in results {
println!("\n Element: {}", bl.element_qualified);
println!(" Description: {}", bl.description);
if let Some(story) = bl.user_story_id {
println!(" User Story: {}", story);
}
if let Some(feature) = bl.feature_id {
println!(" Feature: {}", feature);
}
}
}
Ok(())
}
fn show_annotations(
element: &str,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let result = db::get_business_logic(&db, element)?;
match result {
Some(bl) => {
println!("Annotations for '{}':", element);
println!(" Description: {}", bl.description);
if let Some(story) = bl.user_story_id {
println!(" User Story: {}", story);
}
if let Some(feature) = bl.feature_id {
println!(" Feature: {}", feature);
}
}
None => {
println!("No annotations found for '{}'", element);
}
}
Ok(())
}
fn show_traceability(
db_path: &std::path::Path,
feature: Option<&str>,
user_story: Option<&str>,
all: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
if all {
let all_bl = db::all_business_logic(&db)?;
let mut feature_map: std::collections::HashMap<String, Vec<_>> = std::collections::HashMap::new();
let mut story_map: std::collections::HashMap<String, Vec<_>> = std::collections::HashMap::new();
for bl in &all_bl {
if let Some(ref fid) = bl.feature_id {
feature_map.entry(fid.clone()).or_default().push(bl);
}
if let Some(ref sid) = bl.user_story_id {
story_map.entry(sid.clone()).or_default().push(bl);
}
}
println!("Feature-to-Code Traceability:");
if feature_map.is_empty() {
println!(" No features with linked code elements");
} else {
for (fid, elements) in &feature_map {
println!("\n Feature: {}", fid);
println!(" Code elements ({}):", elements.len());
for elem in elements.iter().take(5) {
println!(" - {}: {}", elem.element_qualified, elem.description);
}
if elements.len() > 5 {
println!(" ... and {} more", elements.len() - 5);
}
}
}
println!("\nUser Story-to-Code Traceability:");
if story_map.is_empty() {
println!(" No user stories with linked code elements");
} else {
for (sid, elements) in &story_map {
println!("\n User Story: {}", sid);
println!(" Code elements ({}):", elements.len());
for elem in elements.iter().take(5) {
println!(" - {}: {}", elem.element_qualified, elem.description);
}
if elements.len() > 5 {
println!(" ... and {} more", elements.len() - 5);
}
}
}
} else if let Some(fid) = feature {
let elements = db::get_by_feature(&db, fid)?;
println!("Feature-to-Code Traceability for '{}':", fid);
if elements.is_empty() {
println!(" No code elements linked to this feature");
} else {
for elem in elements {
println!("\n Element: {}", elem.element_qualified);
println!(" Description: {}", elem.description);
if let Some(story) = elem.user_story_id {
println!(" User Story: {}", story);
}
}
}
} else if let Some(sid) = user_story {
let elements = db::get_by_user_story(&db, sid)?;
println!("User Story-to-Code Traceability for '{}':", sid);
if elements.is_empty() {
println!(" No code elements linked to this user story");
} else {
for elem in elements {
println!("\n Element: {}", elem.element_qualified);
println!(" Description: {}", elem.description);
if let Some(feat) = elem.feature_id {
println!(" Feature: {}", feat);
}
}
}
} else {
println!("Specify --all, --feature <id>, or --user-story <id>");
}
Ok(())
}
fn find_by_domain(
domain: &str,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let results = db::search_business_logic(&db, domain)?;
if results.is_empty() {
println!("No code elements found matching domain '{}'", domain);
} else {
println!(
"Found {} code element(s) for domain '{}':",
results.len(),
domain
);
for bl in results {
println!("\n Element: {}", bl.element_qualified);
println!(" Description: {}", bl.description);
if let Some(story) = bl.user_story_id {
println!(" User Story: {}", story);
}
if let Some(feat) = bl.feature_id {
println!(" Feature: {}", feat);
}
}
}
Ok(())
}
fn run_query(
query: &str,
kind: &str,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let graph_engine = graph::GraphEngine::new(db);
match kind {
"name" => {
let results = graph_engine.search_by_name(query)?;
if results.is_empty() {
println!("No elements found with name matching '{}'", query);
} else {
println!("Found {} element(s) with name '{}':", results.len(), query);
for elem in results {
println!(
" - {} ({}:{} {})",
elem.name, elem.element_type, elem.line_start, elem.line_end
);
println!(" File: {}", elem.file_path);
}
}
}
"type" => {
let results = graph_engine.search_by_type(query)?;
if results.is_empty() {
println!("No elements found of type '{}'", query);
} else {
println!("Found {} element(s) of type '{}':", results.len(), query);
for elem in results {
println!(
" - {} ({}:{})",
elem.qualified_name, elem.line_start, elem.line_end
);
}
}
}
"rel" => {
let results = graph_engine.search_by_relation_type(query)?;
if results.is_empty() {
println!("No relationships found with type '{}'", query);
} else {
println!(
"Found {} relationship(s) of type '{}':",
results.len(),
query
);
for rel in results {
println!(
" - {} -> {} ({})",
rel.source_qualified, rel.target_qualified, rel.rel_type
);
}
}
}
"pattern" => {
let results = graph_engine.search_by_pattern(query)?;
if results.is_empty() {
println!("No elements found matching pattern '{}'", query);
} else {
println!(
"Found {} element(s) matching pattern '{}':",
results.len(),
query
);
for elem in results {
println!(
" - {} ({}:{})",
elem.qualified_name, elem.element_type, elem.file_path
);
}
}
}
_ => {
println!(
"Unknown query kind '{}'. Use: name, type, rel, or pattern",
kind
);
}
}
Ok(())
}
fn find_oversized_functions(
min_lines: u32,
lang: Option<&str>,
db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let db = db::schema::init_db(db_path)?;
let graph_engine = graph::GraphEngine::new(db);
let results = if let Some(language) = lang {
graph_engine
.find_oversized_functions_by_lang(min_lines, language)?
} else {
graph_engine.find_oversized_functions(min_lines)?
};
if results.is_empty() {
println!("No functions found with >= {} lines", min_lines);
} else {
println!(
"Found {} oversized function(s) (>={} lines):",
results.len(),
min_lines
);
for elem in &results {
let line_count = elem.line_end - elem.line_start + 1;
println!(
" - {} ({} lines, {}:{})",
elem.name, line_count, elem.file_path, elem.line_start
);
}
}
Ok(())
}